Hi fellow Rustaceans! 🦀
In today’s article, we're going to create a complete Rust API for minting NFTs on the Ethereum Blockchain. We'll also integrate decentralized file storage with IPFS and implement Smart Contracts using Solidity.
By the conclusion of this article, you'll be proficient in using swagger-ui to engage with the API. You'll also build a solid understanding of how to integrate Web3, RESTful Rust API, Ethereum Blockchain, and Smart Contracts using 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!
Let’s dive right in!
Here's the breakdown of the project:
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/
: This directory holds the Solidity smart contract (MyNFT.sol
) which outlines the principles for creating and exchanging the NFTs.nft-images/
: This directory contains the images or assets linked to each NFT, which are mentioned in the NFT metadata.src/
: This is the directory that houses the Rust files, each playing a unique role in the API functionality.main.rs
: This file serves as the starting point for the API, where the server and routes are configured.error.rs
: This file is responsible for managing custom error handling for the API.ipfs.rs
: Manages communication with IPFS to store metadata off-chain.model.rs
: Details the data models utilized by the API, encompassing structures for NFTs and their accompanying metadata.utils.rs
: Includes various utility functions that are applied throughout the project.web3client.rs
: Facilitates interaction with the Ethereum blockchain through Web3.- `static/`: This directory holds static assets, including the Swagger UI used for API documentation.
.env
: A dotenv file used to handle environment variables, including API keys and blockchain node URLs.Cargo.toml
: This is the manifest file for Rust projects, detailing dependencies and project information.
The smart contract, crafted using Solidity, is deployed onto the Ethereum blockchain. It outlines the guidelines for minting, transferring, and managing the NFTs in compliance with the ERC-721 standard, a popular protocol for NFTs on Ethereum.
IPFS, which stands for InterPlanetary File System, is a system designed for storing off-chain metadata for NFTs. By using IPFS, the metadata—including images and descriptive details—becomes decentralized and protected from tampering. The ipfs.rs
module is responsible for managing the tasks of uploading and retrieving metadata to and from IPFS.
This module sets up a link to the Ethereum blockchain with the help of the Web3 library. It allows the API to engage with the blockchain, carrying out tasks like minting NFTs, fetching NFT details, and monitoring blockchain events.
The main.rs
file establishes the RESTful API server, outlining the routes for different endpoints. These include creating NFTs, retrieving NFT details using a token ID, and listing all NFTs. The Actix-web framework is employed to manage HTTP requests and responses.
Effective error management is essential for creating a strong API. The error.rs
module outlines custom error types and handling methods to provide the client with understandable and useful error messages. Additionally, the utils.rs
module offers utility functions that aid in various tasks within the API, including data validation and formatting.
The MyNFT
contract, crafted using Solidity, builds upon the ERC721URIStorage contract from OpenZeppelin, a renowned library for secure blockchain development. It utilizes the ERC721 standard, widely used for defining NFT ownership, and incorporates the feature of linking NFTs with URI-based metadata.
Key Components
- Token Counters: Leverages OpenZeppelin’s
Counters
utility to keep a distinctive identifier for every minted NFT. - Token Details Structure: Introduces a
TokenDetails
struct that stores critical data for each NFT, such as its ID, name, owner, and related URI. - Mappings: To monitor NFT ownership and specifics, three main mappings are employed:. The mood and tone of the text have been preserved in this rewrite.
- The map
_tokenDetails
associates each token ID with its respectiveTokenDetails
. _ownedTokens
associates an owner's address with a list of the token IDs they possess._ownedTokensIndex
associates each token ID with its specific spot in the owner's token list.
// 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);
}
}
The web3client.rs
file houses the Web3Client
struct, which is designed to provide all the necessary functions for interacting with smart contracts on the Ethereum blockchain using Rust. In the following sections, we'll explore the main features of this implementation.
Web3Client Structure
The Web3Client
struct consists of two primary fields.
web3
: This is an instance of theWeb3
class, symbolizing a link to an Ethereum node.contract
: An instance of aContract
, which symbolizes the smart contract on the Ethereum blockchain that the API will communicate with.
Implementation Details
- The
new
Function: This acts as a constructor for theWeb3Client
struct. It sets up a freshWeb3
instance along with a newContract
instance using the given smart contract address. - Ethereum Node Connection: It sets up an HTTP connection to an Ethereum node as defined by the
ETH_NODE_URL
environment variable. This connection is crucial for executing transactions and interacting with the Ethereum blockchain. - Smart Contract ABI: The ABI, or Application Binary Interface, is crucial for a Rust application to communicate with the smart contract. This ABI is sourced from a file identified by the
CONTRACT_ABI_PATH
environment variable. Typically, this ABI file is created by the Solidity compiler during the smart contract compilation process. - Contract Initialization: By using the ABI and the smart contract’s address, you can create a new
Contract
instance. This instance enables the Rust application to interact with the smart contract by calling its functions, listening to emitted events, and querying 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 })
}
}
The model.rs
file in the Rust NFT API project lays out essential data structures by leveraging Rust's robust type system. It also utilizes serialization features from serde
and incorporates API documentation functionalities 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 framework details the request body used for creating a new NFT. It includes fields for the owner's address, the token's name, the token's URI (which links to the metadata or asset related to the NFT), and the file path of the asset that will be tied to the NFT. The application of pub(crate)
ensures these fields are accessible within the crate.
TokenFileForm
Describes the data structure for a file upload form, aiming to upload files connected to NFTs. The file
field is a vector of bytes (Vec<u8>
), which represents the binary content of the file being uploaded.
ApiResponse
A standard API response format that can be employed to convey the outcome of different API tasks. This structure comprises a success
indicator, which shows if the action was accomplished successfully, a message
that gives extra information or error details, and an optional token_uri
. The token_uri
is particularly useful for operations involving NFTs, as it can return a URI that links to the NFT's metadata or asset.
NftMetadata
This serves as the metadata for an NFT, encompassing the token_id
, owner_address
, token_name
, and token_uri
. It is essential for tasks that involve fetching or presenting NFT information.
UploadResponse
Designed specifically for file upload tasks, this model records the outcome of an upload operation, mainly featuring the token_uri
of the new file. This URI can subsequently be utilized in the minting process or for any other needs that involve referencing the uploaded asset.
The ipfs.rs
module in the Rust NFT API project focuses on managing interactions with the InterPlanetary File System (IPFS), a decentralized storage system. This module plays a key role in enabling file uploads to IPFS, which is essential for storing off-chain metadata or assets associated with NFTs.
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: To make HTTP requests, a Client
instance from Reqwest is set up.
File Path Construction: The function creates the file path by merging the current working directory with a nft-images
subdirectory and the given file_name
.
File Reading: Asynchronously opens the designated file and gathers its bytes into a vector.
Form Preparation: Assembles a multi-part form that includes the file bytes, incorporating the file's name into the form data.
IPFS API Request: Submits the multipart form to the IPFS node’s add endpoint (/api/v0/add
) using a POST request.
Response Handling:
- Upon success, the function analyzes the IPFS response to retrieve the file’s IPFS hash. It then builds a URL that allows the file to be accessed through an IPFS gateway, and forms a successful
ApiResponse
that includes this URL. - In the event of a failure, it creates an
ApiResponse
that signals the upload was unsuccessful.
The error.rs
file is specifically focused on defining and managing various error types that may arise during the application's operation. This module utilizes the thiserror
crate for creating custom error types, and the axum
framework for mapping these errors to suitable HTTP responses. Below is an overview of how error handling is organized 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: This is the main error type used by the application to cover a range of error scenarios, including bad requests, internal server issues, and specific Web3-related errors. It also addresses serialization problems and generic errors. Each variant of
AppError
comes with a detailed error message, which improves the ease of debugging and the clarity of error responses for users. - UploadError and SignatureError: These types of errors are particularly designed to manage issues related to file uploads and signatures. Just like
AppError
, they deliver specific messages tailored to various failure situations.
Error Conversion
- The
From
trait is utilized to enable conversion from more general error types (such asstd::io::Error
andhex::FromHexError
) to more specific application errors (UploadError
andSignatureError
). This approach streamlines error handling throughout the application by encapsulating diverse error sources into clearly defined categories.
Error Responses
- The implementations of the
IntoResponse
trait forAppError
,UploadError
, andSignatureError
transform these errors into HTTP responses. Based on the type of error and its message, a suitable HTTP status code (likeStatusCode::BAD_REQUEST
orStatusCode::INTERNAL_SERVER_ERROR
) is chosen. The error message is then converted into a JSON object, ensuring a consistent and informative error response format for API consumers.
The utils.rs
module illustrates the process of signing data with a private key cryptographically. This function proves beneficial in situations where ensuring data authenticity and integrity is crucial, like in blockchain transactions or secure data transfers.
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 kicks off by converting the hexadecimal private key into a byte array using the hex::decode
function.
Private Key Preparation: It proceeds by transforming the decoded bytes into a SecretKey
object that works seamlessly with the secp256k1
cryptographic library.
Hashing: The data undergoes hashing with the Keccak256
algorithm, a version of SHA-3 frequently employed in Ethereum for hashing tasks.
Signing: The hash is then encapsulated in a Message
type. The secp256k1
library is utilized to sign this message using the specified private key.
Hex Encoding: Lastly, the signature is converted into a streamlined format and then encoded once more into a hexadecimal string, making it simple to transmit and store.
Cryptographic Libraries
- The function utilizes the
secp256k1
library to carry out elliptic curve cryptography. This library is tailored for the secp256k1 curve, which is employed by both Ethereum and Bitcoin for creating and verifying signatures. - The
sha3
crate offers the Keccak256 hashing algorithm, making sure that data signing adheres to the cryptographic standards common in blockchain technologies.
The main.rs
file acts as the starting point for the Rust NFT API, bringing together different parts to deliver a complete backend solution for managing NFTs. Below is a detailed look at each part of the code and what it does:.
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 produces the OpenAPI schema in JSON format, providing developers with a detailed outline of the API endpoints, request bodies, and responses. It utilizes the utoipa
crate for OpenAPI documentation, which facilitates 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 is responsible for processing requests to create new NFTs. It accepts a MintNftRequest
payload, which includes the owner's address, the name of the token, and the file path. This function carries out a simulated signing process, uploads the related file to IPFS, and communicates with a smart contract to mint the NFT, providing the NFT's metadata once it's successfully created.
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 fetches metadata for a particular NFT using its token ID. It accesses the smart contract to obtain information such as the token’s name and URI, delivering 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 allows you to see either all NFTs owned by a specific address or every minted NFT if a particular parameter is included. This process involves querying the smart contract to obtain the token IDs that are owned and then retrieving the metadata for each one.
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 works with the smart contract to create a new NFT, detailing the owner, token URI, and token name. It handles the specifics of building and dispatching 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 retrieves a collection of token IDs associated with a specific address, making it simpler to display the NFTs owned by a user. It interacts with the smart contract and organizes the response for straightforward use.
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 sets up the Axum server by initializing routes for the designated endpoints and configuring middleware like CORS. It binds the server to a specific address and begins 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 handles errors that may arise when serving static files, providing clear reports of any problems encountered while delivering files from the static/swagger-ui
directory.
Every part of the main.rs
file plays a critical role in the Rust NFT API's functionality. It covers everything from setting up endpoints and managing requests to working with external systems such as Ethereum and IPFS, creating a dependable backend for NFT applications.