Implementing a Web3 NFT API in Rust

Hi fellow Rustaceans! 🦀

Implementing a Web3 NFT API in Rust

Hi fellow Rustaceans! 🦀

In today’s article, we will implement a full Rust API for minting NFTs using Ethereum Blockchain, decentralized file storage integration with IPFS, and the Smart Contract implementation in Solidity.

By the end of the article, you’ll be able to interact with the API using swagger-ui, gaining foundational knowledge about how to put together Web3, RESTful Rust API, Ethereum Blockchain, and Smart Contracts with Solidity.

I hope you find this deep dive into the Rust NFT API both informative and engaging, even though it turned out to be a bit longer than our usual reads. For those who prefer a more hands-on approach or would like to explore the code further, I’ve got good news!

🚀 All the details, including the complete codebase and step-by-step instructions to get the project up and running, are neatly organized in the GitHub repository at https://github.com/luishsr/rust-nft-api. Feel free to jump right in, and happy coding!

Let’s dive right in!

Project Structure Overview

The project is structured in the following way:

rust-nft-api/ 
├── contract/ 
│   └── MyNFT.sol 
├── nft-images/ 
│   └── token.jpg 
├── src/ 
│   ├── main.rs 
│   ├── error.rs 
│   ├── ipfs.rs 
│   ├── model.rs 
│   ├── utils.rs 
│   └── web3client.rs 
├── static/ 
│   └── swagger-ui/ 
├── .env 
└── Cargo.toml
  • contract/: Contains the Solidity smart contract (MyNFT.sol) for the NFT, defining the rules for minting and transferring the NFTs.
  • nft-images/: Stores the images or assets associated with each NFT, which are referenced in the NFT metadata.
  • src/: The source directory where the Rust files reside, each serving a specific purpose in the API functionality:
  • main.rs: The entry point of the API, setting up the server and routes.
  • error.rs: Defines custom error handling for the API.
  • ipfs.rs: Handles interaction with IPFS for storing off-chain metadata.
  • model.rs: Defines the data models used by the API, including structures for NFTs and metadata.
  • utils.rs: Contains utility functions used across the project.
  • web3client.rs: Manages the communication with the Ethereum blockchain using Web3.
  • static/: Contains static files, such as the Swagger UI for API documentation.
  • .env: A dotenv file for managing environment variables, such as API keys and blockchain node URLs.
  • Cargo.toml: The Rust package manifest file, listing dependencies and project information.

Key Components and Functionalities

Smart Contract (MyNFT.sol)

The smart contract is written in Solidity and deployed to the Ethereum blockchain. It defines the rules for minting, transferring, and managing the NFTs according to the ERC-721 standard, which is a widely used standard for NFTs on Ethereum.

IPFS Integration (ipfs.rs)

IPFS, or InterPlanetary File System, is used to store off-chain metadata for NFTs. This ensures that the metadata, including images and descriptive information, is decentralized and tamper-proof. The ipfs.rs module handles the uploading and retrieval of metadata to and from IPFS.

Web3 Client (web3client.rs)

This module establishes a connection to the Ethereum blockchain using the Web3 library. It enables the API to interact with the blockchain, performing actions such as minting NFTs, retrieving NFT details, and listening to blockchain events.

API Endpoints (main.rs)

The main.rs file sets up the RESTful API server and defines the routes for various endpoints, such as creating NFTs, fetching NFT details by token ID, and listing all NFTs. It uses the Actix-web framework for handling HTTP requests and responses.

Error Handling and Utilities (error.rs, utils.rs)

Proper error handling is crucial for a robust API. The error.rs module defines custom error types and handling mechanisms to ensure clear and helpful error messages are returned to the client. The utils.rs module contains utility functions that support various operations within the API, such as data validation and formatting.

Step 1. Smart Contract Implementation

The MyNFT contract, developed in Solidity, extends the ERC721URIStorage contract from OpenZeppelin, a standard library for secure blockchain development. It leverages the ERC721 protocol, a popular standard for representing ownership of NFTs, and adds the capability to associate NFTs with URI-based metadata.

Key Components

  • Token Counters: Utilizes OpenZeppelin’s Counters utility to maintain a unique identifier for each NFT minted.
  • Token Details Structure: Defines a TokenDetails struct to hold essential information about each NFT, including its ID, name, owner, and associated URI.
  • Mappings: Three primary mappings are used to track NFT ownership and details:
  • _tokenDetails maps each token ID to its TokenDetails.
  • _ownedTokens maps an owner's address to a list of token IDs they own.
  • _ownedTokensIndex maps a token ID to its position in the owner's list of tokens.
// SPDX-License-Identifier: MIT 
pragma solidity ^0.8.20; 
 
import "@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol"; 
import "@openzeppelin/contracts/utils/Counters.sol"; 
 
contract MyNFT is ERC721URIStorage { 
    using Counters for Counters.Counter; 
    Counters.Counter private _tokenIds; 
 
    struct TokenDetails { 
        uint256 tokenId; 
        string tokenName; 
        address tokenOwner; 
        string tokenURI; 
    } 
 
    mapping(uint256 => TokenDetails) private _tokenDetails; 
    mapping(address => uint256[]) private _ownedTokens; 
    mapping(uint256 => uint256) private _ownedTokensIndex;  // Maps token ID to its index in the owner's token list 
 
    constructor() ERC721("MyNFT", "MNFT") {} 
 
    function mintNFT(address recipient, string memory tokenName, string memory tokenURI) public returns (uint256) { 
        _tokenIds.increment(); 
        uint256 newItemId = _tokenIds.current(); 
        _mint(recipient, newItemId); 
        _setTokenURI(newItemId, tokenURI); 
 
        _tokenDetails[newItemId] = TokenDetails({ 
            tokenId: newItemId, 
            tokenName: tokenName, 
            tokenOwner: recipient, 
            tokenURI: tokenURI 
        }); 
 
        _addTokenToOwnerEnumeration(recipient, newItemId); 
 
        return newItemId; 
    } 
 
    private function _addTokenToOwnerEnumeration(address to, uint256 tokenId) { 
        _ownedTokens[to].push(tokenId); 
        _ownedTokensIndex[tokenId] = _ownedTokens[to].length - 1; 
    } 
 
    function getAllTokensByOwner(address owner) public view returns (uint256[] memory) { 
        if (owner == address(0)) { 
            uint256 totalTokens = _tokenIds.current(); 
            uint256[] memory allTokenIds = new uint256[](totalTokens); 
            for (uint256 i = 0; i < totalTokens; i++) { 
                allTokenIds[i] = i + 1;  // Token IDs are 1-indexed because of the way they are minted 
            } 
            return allTokenIds; 
        } else { 
            return _ownedTokens[owner]; 
        } 
    } 
 
    function getTokenDetails(uint256 tokenId) public view returns (uint256, string memory, address, string memory) { 
        require(_ownerOf(tokenId) != address(0), "ERC721: Query for nonexistent token"); 
 
        TokenDetails memory tokenDetail = _tokenDetails[tokenId]; 
        return (tokenDetail.tokenId, tokenDetail.tokenName, tokenDetail.tokenOwner, tokenDetail.tokenURI); 
    } 
}

Step 2. Writing the Web3Client

The web3client.rsfile contains the implementation of the Web3Client struct, which encapsulates the functionality needed to interact with smart contracts on the Ethereum network using the Rust programming language. Below, we delve into the key aspects of this implementation.

Web3Client Structure

The Web3Client struct contains two main fields:

  • web3: An instance of the Web3 type, representing a connection to an Ethereum node.
  • contract: A Contract instance, representing the smart contract on the Ethereum blockchain that the API will interact with.

Implementation Details

  • The new Function: This is a constructor for the Web3Client struct. It initializes a new Web3 instance and a new Contract instance with the provided smart contract address.
  • Ethereum Node Connection: It establishes an HTTP connection to an Ethereum node specified by the ETH_NODE_URL environment variable. This connection is essential for sending transactions and making calls to the Ethereum blockchain.
  • Smart Contract ABI: The ABI (Application Binary Interface) of the smart contract is necessary for the Rust application to understand how to interact with the contract. The ABI is loaded from a file specified by the CONTRACT_ABI_PATH environment variable. This ABI file is usually generated by the Solidity compiler when the smart contract is compiled.
  • Contract Initialization: With the ABI and the smart contract’s address, a new Contract instance is created. This instance allows the Rust application to call functions of the smart contract, listen to events emitted by it, and query its state.
use std::env; 
use std::error::Error; 
use web3::contract::Contract; 
use web3::transports::Http; 
use web3::{ethabi, Web3}; 
 
pub struct Web3Client { 
    pub web3: Web3<Http>, 
    pub contract: Contract<Http>, 
} 
 
impl Web3Client { 
    pub fn new(contract_address: &str) -> Result<Self, Box<dyn Error>> { 
        let http = Http::new(&env::var("ETH_NODE_URL")?)?; 
        let web3 = Web3::new(http); 
 
        let contract_abi_path = env::var("CONTRACT_ABI_PATH")?; 
        let contract_abi_file = std::fs::File::open(contract_abi_path)?; 
        let contract_abi: ethabi::Contract = serde_json::from_reader(contract_abi_file)?; 
 
        let contract = Contract::new(web3.eth(), contract_address.parse()?, contract_abi); 
 
        Ok(Web3Client { web3, contract }) 
    } 
}

Step 3. Data Structure

The model.rs file within the Rust NFT API project defines several key data structures using Rust's powerful type system, combined with serialization capabilities provided by serde, and API documentation features from utoipa.

use serde::{Deserialize, Serialize}; 
use utoipa::Component; 
 
#[derive(Serialize, Deserialize, Component)] 
pub struct MintNftRequest { 
    pub(crate) owner_address: String, 
    pub(crate) token_name: String, 
    pub(crate) token_uri: String, 
    pub(crate) file_path: String, 
} 
 
#[derive(Serialize, Deserialize, Component)] 
pub struct TokenFileForm { 
    file: Vec<u8>, 
} 
 
#[derive(Serialize, Deserialize, Component)] 
pub struct ApiResponse { 
    pub(crate) success: bool, 
    pub(crate) message: String, 
    pub(crate) token_uri: Option<String>, 
} 
 
#[derive(Serialize, Deserialize, Component)] 
pub struct NftMetadata { 
    pub(crate) token_id: String, 
    pub(crate) owner_address: String, 
    pub(crate) token_name: String, 
    pub(crate) token_uri: String, 
} 
 
#[derive(Serialize, Deserialize)] 
pub struct UploadResponse { 
    token_uri: String, 
}

MintNftRequest

This structure represents the request body for minting a new NFT. It contains fields for the owner’s address, the token’s name, the token’s URI (which points to the metadata or asset associated with the NFT), and the file path to the asset to be associated with the NFT. The use of pub(crate) makes these fields accessible within the crate.

TokenFileForm

Defines the data structure for a file upload form, specifically for uploading files associated with NFTs. The file field is a vector of bytes (Vec<u8>), representing the binary content of the file being uploaded.

ApiResponse

A generic API response structure that can be used to communicate the outcome of various API operations. It includes a success flag indicating whether the operation was successful, a message providing additional information or error details, and an optional token_uri which is especially relevant in operations involving NFTs, where a URI pointing to the NFT's metadata or asset might be returned.

NftMetadata

Represents the metadata associated with an NFT. It includes the token_id, owner_address, token_name, and token_uri. This model is crucial for operations that involve retrieving or displaying NFT details.

UploadResponse

Specifically tailored for file upload operations, this model captures the response of an upload operation, primarily containing the token_uri of the uploaded file. This URI can then be used in the minting process or for other purposes that require a reference to the uploaded asset.

Step 4. Interfacing with IPFS

The ipfs.rs module within the Rust NFT API project is dedicated to handling interactions with the InterPlanetary File System (IPFS), a decentralized storage solution. This module facilitates the uploading of files to IPFS, which is crucial for storing off-chain NFT metadata or assets.

use crate::model::ApiResponse; 
use axum::Json; 
use reqwest::Client; 
use serde_json::Value; 
use std::convert::Infallible; 
use std::env; 
use tokio::fs::File; 
use tokio::io::AsyncReadExt; 
 
pub async fn file_upload(file_name: String) -> Result<Json<ApiResponse>, Infallible> { 
    let client = Client::new(); 
    let ipfs_api_endpoint = "http://127.0.0.1:5001/api/v0/add"; 
 
    // Get the current directory 
    let mut path = env::current_dir().expect("Failed to get current directory"); 
    // Append the 'nft-images' subdirectory to the path 
    path.push("nft-images"); 
    // Append the file name to the path 
    path.push(file_name); 
 
    //println!("Full path: {}", path.display()); 
 
    // Open the file asynchronously 
    let mut file = File::open(path.clone()).await.expect("Failed to open file"); 
 
    // Read file bytes 
    let mut file_bytes = Vec::new(); 
    file.read_to_end(&mut file_bytes) 
        .await 
        .expect("Failed to read file bytes"); 
 
    // Extract the file name from the path 
    let file_name = path 
        .file_name() 
        .unwrap() 
        .to_str() 
        .unwrap_or_default() 
        .to_string(); 
 
    let form = reqwest::multipart::Form::new().part( 
        "file", 
        reqwest::multipart::Part::stream(file_bytes).file_name(file_name), 
    ); 
 
    let response = client 
        .post(ipfs_api_endpoint) 
        .multipart(form) 
        .send() 
        .await 
        .expect("Failed to send file to IPFS"); 
 
    if response.status().is_success() { 
        let response_body = response 
            .text() 
            .await 
            .expect("Failed to read response body as text"); 
 
        let ipfs_response: Value = 
            serde_json::from_str(&response_body).expect("Failed to parse IPFS response"); 
        let ipfs_hash = format!( 
            "https://ipfs.io/ipfs/{}", 
            ipfs_response["Hash"].as_str().unwrap_or_default() 
        ); 
 
        Ok(Json(ApiResponse { 
            success: true, 
            message: "File uploaded to IPFS successfully.".to_string(), 
            token_uri: Some(ipfs_hash), 
        })) 
    } else { 
        Ok(Json(ApiResponse { 
            success: false, 
            message: "IPFS upload failed.".to_string(), 
            token_uri: None, 
        })) 
    } 
}

Process Flow

Initialization: A Client instance from Reqwest is created for making HTTP requests.

File Path Construction: The function constructs the file path by combining the current working directory, a nft-images subdirectory, and the provided file_name.

File Reading: Opens and reads the specified file asynchronously, collecting its bytes into a vector.

Form Preparation: Prepares a multipart form containing the file bytes, using the file’s name as part of the form data.

IPFS API Request: Sends the multipart form to the IPFS node’s add endpoint (/api/v0/add) via a POST request.

Response Handling:

  • On success, parses the IPFS response to extract the file’s IPFS hash, constructs a URL to access the file via an IPFS gateway, and creates a successful ApiResponse containing this URL.
  • On failure, constructs an ApiResponse indicating the upload failure.

Step 5. Error Handling

The error.rs file is dedicated to defining and managing various error types that can occur during the application's operation. This module uses the thiserror crate for defining custom error types and the axum framework for mapping these errors to appropriate HTTP responses. Here's an overview of how error handling is structured within this file:

use axum::{ 
    http::StatusCode, 
    response::{IntoResponse, Response}, 
    Json, 
}; 
use serde_json::json; 
use thiserror::Error; 
 
// Define a custom application error type using `thiserror` 
#[derive(Error, Debug)] 
pub enum AppError { 
    #[error("Bad request: {0}")] 
    BadRequest(String), 
 
    #[error("Internal server error: {0}")] 
    InternalServerError(String), 
 
    #[error("Web3 error: {0}")] 
    Web3Error(#[from] web3::Error), 
 
    #[error("Serialization error: {0}")] 
    SerdeError(#[from] serde_json::Error), 
 
    #[error("Internal error: {0}")] 
    GenericError(String), 
 
    #[error("Smart contract error: {0}")] 
    NotFound(String), 
} 
 
impl From<Box<dyn std::error::Error>> for AppError { 
    fn from(err: Box<dyn std::error::Error>) -> Self { 
        AppError::GenericError(format!("An error occurred: {}", err)) 
    } 
} 
 
// Implement `IntoResponse` for `AppError` to convert it into an HTTP response 
impl IntoResponse for AppError { 
    fn into_response(self) -> Response { 
        let (status, error_message) = match &self { 
            AppError::BadRequest(message) => (StatusCode::BAD_REQUEST, message.clone()), 
            AppError::InternalServerError(message) => { 
                (StatusCode::INTERNAL_SERVER_ERROR, message.clone()) 
            } 
            AppError::Web3Error(message) => { 
                (StatusCode::INTERNAL_SERVER_ERROR, message.to_string()) 
            } 
            AppError::SerdeError(message) => { 
                (StatusCode::INTERNAL_SERVER_ERROR, message.to_string()) 
            } 
            AppError::GenericError(message) => (StatusCode::INTERNAL_SERVER_ERROR, message.clone()), 
            AppError::NotFound(message) => (StatusCode::INTERNAL_SERVER_ERROR, message.clone()), 
        }; 
 
        let body = Json(json!({ "error": error_message })).into_response(); 
        (status, body).into_response() 
    } 
} 
 
// Custom UploadError type for file upload errors 
#[derive(Error, Debug)] 
pub enum UploadError { 
    #[error("IO error: {0}")] 
    IoError(#[from] std::io::Error), 
} 
 
#[derive(Error, Debug)] 
pub enum SignatureError { 
    #[error("Hex decoding error: {0}")] 
    HexDecodeError(#[from] hex::FromHexError), 
} 
 
impl From<SignatureError> for AppError { 
    fn from(err: SignatureError) -> AppError { 
        match err { 
            SignatureError::HexDecodeError(_) => { 
                AppError::BadRequest("Invalid hex format".to_string()) 
            } 
        } 
    } 
} 
 
// Implement `IntoResponse` for `UploadError` to convert it into an HTTP response 
impl IntoResponse for UploadError { 
    fn into_response(self) -> Response { 
        let (status, error_message) = match self { 
            UploadError::IoError(_) => ( 
                StatusCode::INTERNAL_SERVER_ERROR, 
                "Internal server error".to_string(), 
            ), 
        }; 
 
        let body = Json(json!({ "error": error_message })).into_response(); 
        (status, body).into_response() 
    } 
}

Custom Application Error Types

  • AppError: The primary error type for the application, encompassing various error scenarios such as bad requests, internal server errors, and specific errors related to Web3 interactions, serialization issues, and generic errors. Each variant of AppError is associated with a descriptive error message, enhancing the debuggability and user-friendliness of error responses.
  • UploadError and SignatureError: These are specialized error types for handling file upload errors and signature-related errors, respectively. Like AppError, they provide specific error messages for different failure scenarios.

Error Conversion

  • The From trait is implemented to allow conversion from broader error types (like std::io::Error and hex::FromHexError) to the more specific application errors (UploadError and SignatureError). This facilitates seamless error handling across different parts of the application by encapsulating various error sources into well-defined categories.

Error Responses

  • The IntoResponse trait implementations for AppError, UploadError, and SignatureError convert these errors into HTTP responses. Depending on the error type and its message, an appropriate HTTP status code (such as StatusCode::BAD_REQUEST or StatusCode::INTERNAL_SERVER_ERROR) is selected. The error message is then serialized into a JSON object, providing a consistent and informative error response format for API consumers.

Step 6. Utility Functions

The utils.rs module demonstrates how to sign data using a private key in a cryptographic manner. This function is particularly useful in scenarios where data authenticity and integrity need to be verified, such as in blockchain transactions or secure data exchanges.

use secp256k1::{Message, Secp256k1, SecretKey}; 
use sha3::{Digest, Keccak256}; 
use std::error::Error; 
 
pub fn mock_sign_data(data: &[u8], private_key_hex: &str) -> Result<String, Box<dyn Error>> { 
    // Decode the hex private key 
    let private_key = SecretKey::from_slice(&hex::decode(private_key_hex)?)?; 
 
    // Create a new Secp256k1 context 
    let secp = Secp256k1::new(); 
 
    // Hash the data using Keccak256 
    let data_hash = Keccak256::digest(data); 
 
    // Sign the hash 
    let message = Message::from_digest_slice(&data_hash)?; 
    let signature = secp.sign_ecdsa(&message, &private_key); 
 
    // Encode the signature as hex 
    Ok(hex::encode(signature.serialize_compact())) 
}

Process:

Hex Decoding: The function starts by decoding the hexadecimal private key into bytes using the hex::decode function.

Private Key Preparation: It then converts the decoded bytes into a SecretKey instance compatible with the secp256k1 cryptographic library.

Hashing: The data is hashed using the Keccak256 algorithm, which is a variant of SHA-3 and commonly used in Ethereum for hashing purposes.

Signing: The hash is then wrapped into a Message type, and the secp256k1 library is used to sign this message with the provided private key.

Hex Encoding: Finally, the signature is serialized into a compact format and encoded back into a hexadecimal string to facilitate easy transmission and storage.

Cryptographic Libraries

  • The function leverages the secp256k1 library for elliptic curve cryptography, specifically designed for the secp256k1 curve used by Ethereum and Bitcoin for signature generation and verification.
  • The sha3 crate provides the implementation of the Keccak256 hashing algorithm, ensuring that the data signing process aligns with cryptographic practices prevalent in blockchain technologies.

Step 7. The core API implementation

The main.rs file serves as the entry point for the Rust NFT API, orchestrating various components to provide a comprehensive backend for NFT management. Here's a breakdown of each code section along with its explanation:

OpenAPI Schema Generation

#[derive(utoipa::OpenApi)] 
#[openapi( 
    handlers(process_mint_nft, get_nft_metadata, list_tokens), 
    components(MintNftRequest, NftMetadata) 
)] 
struct ApiDoc; 
 
// Return JSON version of the OpenAPI schema 
#[utoipa::path( 
get, 
    path = "/api/openapi.json", 
responses( 
    (status = 200, description = "JSON file", body = Json ) 
) 
)] 
async fn openapi() -> Json<utoipa::openapi::OpenApi> { 
    Json(ApiDoc::openapi()) 
}

This function generates the OpenAPI schema in JSON format, offering developers a clear specification of the API endpoints, request bodies, and responses. It leverages the utoipa crate for OpenAPI documentation, aiding in API discovery and interaction.

NFT Minting Endpoint

async fn process_mint_nft( 
    Extension(web3_client): Extension<Arc<Web3Client>>, 
    Json(payload): Json<MintNftRequest>, 
) -> Result<Json<NftMetadata>, AppError> { 
#[utoipa::path( 
post, 
    path = "/mint", 
    request_body = MintNftRequest, 
responses( 
    (status = 200, description = "NFT minted successfully", body = NftMetadata), 
    (status = 400, description = "Bad Request"), 
    (status = 500, description = "Internal Server Error") 
) 
)] 
async fn process_mint_nft( 
    Extension(web3_client): Extension<Arc<Web3Client>>, 
    Json(payload): Json<MintNftRequest>, 
) -> Result<Json<NftMetadata>, AppError> { 
    let owner_address = payload 
        .owner_address 
        .parse::<Address>() 
        .map_err(|_| AppError::BadRequest("Invalid owner address".into()))?; 
 
    // Retrieve the mock private key from environment variables 
    let mock_private_key = env::var("MOCK_PRIVATE_KEY").expect("MOCK_PRIVATE_KEY must be set"); 
 
    // Simulate data to be signed 
    let data_to_sign = format!("{}:{}", payload.owner_address, payload.token_name).into_bytes(); 
 
    // Perform mock signature 
    let _mock_signature = mock_sign_data(&data_to_sign, &mock_private_key)?; 
 
    let upload_response = match ipfs::file_upload(payload.file_path.clone()).await { 
        Ok(response) => response, 
        Err(_) => unreachable!(), // Since Err is Infallible, this branch will never be executed 
    }; 
 
    let uploaded_token_uri = upload_response.token_uri.clone().unwrap(); 
 
    // Call mint_nft using the file_url as the token_uri 
    let token_id = mint_nft( 
        &web3_client.web3, 
        &web3_client.contract, 
        owner_address, 
        uploaded_token_uri.clone(), 
        payload.token_name.clone(), 
    ) 
    .await 
    .map_err(|e| AppError::InternalServerError(format!("Failed to mint NFT: {}", e)))?; 
 
    Ok(Json(NftMetadata { 
        token_id: token_id.to_string(), 
        owner_address: payload.owner_address, 
        token_name: payload.token_name, 
        token_uri: uploaded_token_uri.clone(), 
    })) 
}     
 
 
}

The process_mint_nft endpoint handles requests to mint new NFTs. It takes a MintNftRequest payload containing the NFT owner's address, token name, and file path. The function performs a mock signing operation, uploads the associated file to IPFS, and interacts with a smart contract to mint the NFT, returning the NFT's metadata upon success.

NFT Metadata Retrieval Endpoint

#[utoipa::path( 
get, 
    path = "/nft/{token_id}", 
params( 
    ("token_id" = String, )), 
responses( 
    (status = 200, description = "NFT metadata retrieved successfully", body = NftMetadata), 
    (status = 400, description = "Bad Request"), 
    (status = 500, description = "Internal Server Error") 
) 
)] 
async fn get_nft_metadata( 
    Extension(web3_client): Extension<Arc<Web3Client>>, 
    Path(token_id): Path<String>, 
) -> Result<Json<NftMetadata>, AppError> { 
    let parsed_token_id = token_id 
        .parse::<U256>() 
        .map_err(|_| AppError::BadRequest("Invalid token ID".into()))?; 
 
    match get_nft_details(&web3_client.contract, parsed_token_id.to_string()).await { 
        Ok((_, token_name, token_owner, token_uri)) => { 
            // Construct NftMetadata for the token 
            let nft_metadata = NftMetadata { 
                token_id: parsed_token_id.to_string(), 
                owner_address: format!("{:?}", token_owner), 
                token_name, 
                token_uri, 
            }; 
 
            Ok(Json(nft_metadata)) 
        } 
        Err(AppError::NotFound(msg)) => Err(AppError::NotFound(msg)), 
        Err(_) => Err(AppError::InternalServerError( 
            "Failed to retrieve NFT details".into(), 
        )), 
    } 
}

This endpoint retrieves metadata for a specific NFT given its token ID. It queries the smart contract for details like the token’s name and URI, providing a structured response with the NFT’s metadata.

NFT Listing Endpoint

#[utoipa::path( 
get, 
    path = "/tokens/{owner_address}", 
params( 
    ("owner_address" = Option<String>, description = "Owner address to filter tokens by. Type 0 to list all tokens.") 
), 
responses( 
    (status = 200, description = "Token list retrieved successfully", body = [NftMetadata]), 
    (status = 400, description = "Bad Request"), 
    (status = 500, description = "Internal Server Error") 
) 
)] 
 
async fn list_tokens( 
    Extension(web3_client): Extension<Arc<Web3Client>>, 
    token_owner: Option<Path<String>>, 
) -> Result<Json<Vec<NftMetadata>>, StatusCode> { 
    let owner_address = match token_owner { 
        Some(ref owner) if owner.0 != "0" => match owner.0.parse::<Address>() { 
            // Check if owner is not "0" 
            Ok(addr) => addr, 
            Err(_) => return Err(StatusCode::BAD_REQUEST), 
        }, 
        _ => Address::default(), // Treat "0" or None as an indication to list all tokens 
    }; 
 
    let token_ids = 
        match get_all_owned_tokens(&web3_client.web3, &web3_client.contract, owner_address).await { 
            Ok(ids) => ids, 
            Err(_) => return Err(StatusCode::INTERNAL_SERVER_ERROR), 
        }; 
 
    let mut nft_metadata_list = Vec::new(); 
    for token_id in token_ids { 
        match get_nft_details(&web3_client.contract, token_id.to_string()).await { 
            Ok((_, token_name, _onwer, token_uri)) => { 
                let nft_metadata = NftMetadata { 
                    token_id: token_id.to_string(), 
                    owner_address: _onwer.to_string(), 
                    token_name, 
                    token_uri, 
                }; 
                nft_metadata_list.push(nft_metadata); 
            } 
            Err(e) => eprintln!("Failed to get metadata for token {}: {:?}", token_id, e), // Log or handle errors as needed 
        } 
    } 
 
    Ok(Json(nft_metadata_list)) 
}

The list_tokens endpoint lists all NFTs owned by a specific address or all minted NFTs if a special parameter is provided. It involves querying the smart contract for owned token IDs and fetching metadata for each.

Mint NFT Utility Function

#[utoipa::path( 
post, 
    path = "/mint", 
    request_body = MintNftRequest, 
responses( 
    (status = 200, description = "NFT minted successfully", body = NftMetadata), 
    (status = 400, description = "Bad Request"), 
    (status = 500, description = "Internal Server Error") 
) 
)] 
async fn process_mint_nft( 
    Extension(web3_client): Extension<Arc<Web3Client>>, 
    Json(payload): Json<MintNftRequest>, 
) -> Result<Json<NftMetadata>, AppError> { 
    let owner_address = payload 
        .owner_address 
        .parse::<Address>() 
        .map_err(|_| AppError::BadRequest("Invalid owner address".into()))?; 
 
    // Retrieve the mock private key from environment variables 
    let mock_private_key = env::var("MOCK_PRIVATE_KEY").expect("MOCK_PRIVATE_KEY must be set"); 
 
    // Simulate data to be signed 
    let data_to_sign = format!("{}:{}", payload.owner_address, payload.token_name).into_bytes(); 
 
    // Perform mock signature 
    let _mock_signature = mock_sign_data(&data_to_sign, &mock_private_key)?; 
 
    let upload_response = match ipfs::file_upload(payload.file_path.clone()).await { 
        Ok(response) => response, 
        Err(_) => unreachable!(), // Since Err is Infallible, this branch will never be executed 
    }; 
 
    let uploaded_token_uri = upload_response.token_uri.clone().unwrap(); 
 
    // Call mint_nft using the file_url as the token_uri 
    let token_id = mint_nft( 
        &web3_client.web3, 
        &web3_client.contract, 
        owner_address, 
        uploaded_token_uri.clone(), 
        payload.token_name.clone(), 
    ) 
    .await 
    .map_err(|e| AppError::InternalServerError(format!("Failed to mint NFT: {}", e)))?; 
 
    Ok(Json(NftMetadata { 
        token_id: token_id.to_string(), 
        owner_address: payload.owner_address, 
        token_name: payload.token_name, 
        token_uri: uploaded_token_uri.clone(), 
    })) 
}

This utility function interacts with the smart contract to mint a new NFT, specifying the owner, token URI, and token name. It encapsulates the details of constructing and sending the transaction to the blockchain.

Owned Tokens Retrieval Utility Function

async fn get_all_owned_tokens<T: Transport>( 
    _web3: &Web3<T>, 
    contract: &Contract<T>, 
    owner: Address, 
) -> Result<Vec<u64>, Box<dyn Error>> { 
    let options = Options::with(|opt| { 
        opt.gas = Some(1_000_000.into()); 
    }); 
 
    let result: Vec<u64> = contract 
        .query("getAllTokensByOwner", owner, owner, options, None) 
        .await?; 
 
    Ok(result) 
}

This function fetches a list of token IDs owned by a given address, facilitating the listing of user-owned NFTs. It queries the smart contract and formats the response for easy consumption.

Server Initialization and Route Definition

#[tokio::main] 
async fn main() -> Result<(), Box<dyn Error>> { 
    let _ = dotenvy::dotenv(); 
 
    let args: Vec<String> = env::args().collect(); 
 
    if args.len() < 2 { 
        eprintln!("Usage: {} <smart_contract_address>", args[0]); 
        std::process::exit(1); 
    } 
 
    let contract_address = &args[1]; 
 
    let web3_client = Arc::new(Web3Client::new(contract_address).unwrap()); 
 
    let app = Router::new() 
        .route("/mint", post(process_mint_nft)) 
        .route("/nft/:token_id", get(get_nft_metadata)) 
        .route("/tokens/:owner_address?", get(list_tokens)) 
        .route("/api/openapi.json", get(openapi)) 
        .nest( 
            "/swagger-ui", 
            get_service(ServeDir::new("./static/swagger-ui/")).handle_error(handle_serve_dir_error), 
        ) 
        .layer(Extension(web3_client)) 
        .layer( 
            tower_http::cors::CorsLayer::new() 
                .allow_origin(tower_http::cors::Any) 
                .allow_headers(vec![CONTENT_TYPE, AUTHORIZATION, ACCEPT]) 
                .allow_methods(vec![axum::http::Method::GET, axum::http::Method::POST]), 
        ); 
 
    let addr = SocketAddr::from(([127, 0, 0, 1], 3010)); 
 
    println!("Listening on https://{}", addr); 
 
    if let Err(e) = axum::Server::bind(&addr) 
        .serve(app.into_make_service()) 
        .await 
    { 
        eprintln!("Server failed to start: {}", e); 
        std::process::exit(1); 
    } 
 
    Ok(()) 
}

The main function initializes the Axum server, setting up routes for the defined endpoints and configuring middleware such as CORS. It binds the server to a specified address, starting to listen for incoming requests.

Static File Serving Error Handling

async fn handle_serve_dir_error(error: io::Error) -> (StatusCode, String) { 
    ( 
        StatusCode::INTERNAL_SERVER_ERROR, 
        format!("Failed to serve static file: {}", error), 
    ) 
}

This function provides error handling for serving static files, ensuring clear reporting of any issues encountered while serving files from the static/swagger-ui directory.

Each section of the main.rs file contributes to the overall functionality of the Rust NFT API, from defining endpoints and handling requests to interacting with external systems like Ethereum and IPFS, providing a robust backend for NFT applications.

Check out the GitHub repo! 🚀

I hope you found this deep dive into the Rust NFT API both informative and engaging, even though it turned out to be a bit longer than our usual reads.

For those who prefer a more hands-on approach or would like to explore the code further, I’ve got good news!

All the details, including the complete codebase and step-by-step instructions to get the project up and running, are neatly organized in the GitHub repository at https://github.com/luishsr/rust-nft-api. Feel free to jump right in, and happy coding!

🚀 Explore More by Luis Soares

đź“š Learning Hub: Expand your knowledge in various tech domains, including Rust, Software Development, Cloud Computing, Cyber Security, Blockchain, and Linux, through my extensive resource collection:

  • Hands-On Tutorials with GitHub Repos: Gain practical skills across different technologies with step-by-step tutorials, complemented by dedicated GitHub repositories. Access Tutorials
  • In-Depth Guides & Articles: Deep dive into core concepts of Rust, Software Development, Cloud Computing, and more, with detailed guides and articles filled with practical examples. Read More
  • E-Books Collection: Enhance your understanding of various tech fields with a series of free e-Books, including titles like “Mastering Rust Ownership” and “Application Security Guide” Download eBook
  • Project Showcases: Discover a range of fully functional projects across different domains, such as an API Gateway, Blockchain Network, Cyber Security Tools, Cloud Services, and more. View Projects
  • LinkedIn Newsletter: Stay ahead in the fast-evolving tech landscape with regular updates and insights on Rust, Software Development, and emerging technologies by subscribing to my newsletter on LinkedIn. Subscribe Here

đź”— Connect with Me:

  • Medium: Read my articles on Medium and give claps if you find them helpful. It motivates me to keep writing and sharing Rust content. Follow on Medium
  • Personal Blog: Discover more on my personal blog, a hub for all my Rust-related content. Visit Blog
  • LinkedIn: Join my professional network for more insightful discussions and updates. Connect on LinkedIn
  • Twitter: Follow me on Twitter for quick updates and thoughts on Rust programming. Follow on Twitter

Wanna talk? Leave a comment or drop me a message!

All the best,

Luis Soares
luis.soares@linux.com

Senior Software Engineer | Cloud Engineer | SRE | Tech Lead | Rust | Golang | Java | ML AI & Statistics | Web3 & Blockchain

Read more