Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
126 changes: 122 additions & 4 deletions api/src/routes/assets.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,21 @@ use crate::models::Asset;
const DEFAULT_PAGE_SIZE: usize = 50;
const MAX_PAGE_SIZE: usize = 100;

#[derive(Debug, Clone, Copy, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AssetSort {
Valuation,
Holders,
CreatedAt,
}

#[derive(Debug, Clone, Copy, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SortDirection {
Asc,
Desc,
}

/// Optional filters and pagination for the asset list.
#[derive(Debug, Deserialize)]
pub struct AssetQuery {
Expand All @@ -26,19 +41,45 @@ pub struct AssetQuery {
/// Limit the number of matching assets returned. Defaults to 50 and is capped at 100.
#[serde(default)]
pub limit: Option<usize>,
/// Sort by the provided field before pagination. Accepted values: `valuation`, `holders`, `created_at`.
#[serde(default, alias = "sort_by")]
pub sort: Option<AssetSort>,
/// Sort order. Defaults to `desc`.
#[serde(default, alias = "direction")]
pub order: Option<SortDirection>,
}

fn sort_key_value(asset: &Asset, sort: AssetSort) -> i128 {
match sort {
AssetSort::Valuation => asset.valuation_cents.parse().unwrap_or(0),
AssetSort::Holders => asset.holders as i128,
AssetSort::CreatedAt => asset.created_at_ledger as i128,
}
}

fn sort_assets(assets: &mut [Asset], sort: AssetSort, order: SortDirection) {
assets.sort_by(|left, right| {
let left_value = sort_key_value(left, sort);
let right_value = sort_key_value(right, sort);
let ordering = left_value.cmp(&right_value);
match order {
SortDirection::Asc => ordering,
SortDirection::Desc => ordering.reverse(),
}
});
}

/// All tokenized assets with valuation, supply and holder counts.
///
/// Supports optional `?asset_type=`, `?active=`, `?offset=` and `?limit=` query filters.
/// Supports optional `?asset_type=`, `?active=`, `?offset=`, `?limit=`, `?sort=` and `?order=` query filters.
pub async fn list(
State(state): State<AppState>,
Query(query): Query<AssetQuery>,
) -> Json<Vec<Asset>> {
let snap = state.snapshot();
let offset = query.offset.unwrap_or(0);
let limit = query.limit.unwrap_or(DEFAULT_PAGE_SIZE).min(MAX_PAGE_SIZE);
let assets = snap
let mut assets: Vec<_> = snap
.assets
.into_iter()
.filter(|a| {
Expand All @@ -48,9 +89,14 @@ pub async fn list(
.is_none_or(|t| a.asset_type == t)
})
.filter(|a| query.active.is_none_or(|active| a.active == active))
.skip(offset)
.take(limit)
.collect();

if let Some(sort) = query.sort {
let order = query.order.unwrap_or(SortDirection::Desc);
sort_assets(&mut assets, sort, order);
}

let assets = assets.into_iter().skip(offset).take(limit).collect();
Json(assets)
}

Expand Down Expand Up @@ -218,6 +264,78 @@ mod tests {
assert_eq!(result.len(), 100);
}

#[tokio::test]
async fn sort_by_valuation_holders_and_created_at() {
let mut asset_a = stub_asset(1, "real_estate", true);
asset_a.valuation_cents = "100".to_string();
asset_a.holders = 4;
asset_a.created_at_ledger = 200;

let mut asset_b = stub_asset(2, "real_estate", true);
asset_b.valuation_cents = "300".to_string();
asset_b.holders = 2;
asset_b.created_at_ledger = 400;

let mut asset_c = stub_asset(3, "real_estate", true);
asset_c.valuation_cents = "200".to_string();
asset_c.holders = 6;
asset_c.created_at_ledger = 100;

let by_valuation = get_assets(
list_router(vec![asset_a.clone(), asset_b.clone(), asset_c.clone()]),
"/assets?sort=valuation",
)
.await;
assert_eq!(by_valuation.iter().map(|a| a.id).collect::<Vec<_>>(), vec![2, 3, 1]);

let by_holders = get_assets(
list_router(vec![asset_a.clone(), asset_b.clone(), asset_c.clone()]),
"/assets?sort=holders",
)
.await;
assert_eq!(by_holders.iter().map(|a| a.id).collect::<Vec<_>>(), vec![3, 1, 2]);

let by_created_at = get_assets(
list_router(vec![asset_a.clone(), asset_b.clone(), asset_c.clone()]),
"/assets?sort=created_at",
)
.await;
assert_eq!(by_created_at.iter().map(|a| a.id).collect::<Vec<_>>(), vec![2, 1, 3]);
}

#[tokio::test]
async fn unknown_query_parameter_is_ignored() {
let assets = vec![
stub_asset(1, "real_estate", true),
stub_asset(2, "real_estate", false),
stub_asset(3, "bond", true),
];

let result = get_assets(
list_router(assets),
"/assets?asset_type=real_estate&active=true&unexpected=ignored",
)
.await;
assert_eq!(result.len(), 1);
assert_eq!(result[0].id, 1);
}

#[tokio::test]
async fn post_to_get_only_endpoint_returns_405() {
let app = list_router(vec![stub_asset(1, "real_estate", true)]);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/assets")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::METHOD_NOT_ALLOWED);
}

// #194 – non-numeric asset id returns 400, not 404
#[tokio::test]
async fn get_asset_by_non_numeric_id_returns_400() {
Expand Down
4 changes: 3 additions & 1 deletion docs/app/docs/api/assets/page.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,13 @@ Returns an array of asset objects.
| `active` | boolean | Filter by active status |
| `offset` | integer | Skip the first N matching assets. Default: `0`. |
| `limit` | integer | Page size. Default: `50`; maximum: `100`. |
| `sort` | string | Sort the filtered list by `valuation`, `holders`, or `created_at`. Default: `valuation` when passed; otherwise preserves the server ordering. |
| `order` | string | Optional sort direction: `asc` or `desc`. Default: `desc`. |

Results are paginated to keep large collections bounded. Use `offset` and `limit` together to walk the list in chunks.

```bash
curl "$API_BASE_URL/assets?asset_type=real_estate&active=true&offset=0&limit=50"
curl "$API_BASE_URL/assets?asset_type=real_estate&active=true&sort=valuation&order=desc&offset=0&limit=50"
```

**Response schema** (per asset):
Expand Down
18 changes: 2 additions & 16 deletions docs/app/docs/integration/page.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -34,23 +34,9 @@ curl $API_BASE_URL/assets/1/dividends
### TypeScript client

```ts
const API = process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
import type { Asset } from "@/lib/api-client";

export interface Asset {
id: number;
token_contract: string;
name: string;
symbol: string;
asset_type: string;
valuation_cents: string;
valuation_usd: number;
decimals: number;
total_supply: string;
holders: number;
active: boolean;
paused: boolean;
compliance_contract: string;
}
const API = process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";

export async function getAssets(): Promise<Asset[]> {
const res = await fetch(`${API}/assets`, { cache: "no-store" });
Expand Down
71 changes: 71 additions & 0 deletions docs/lib/api-client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
export type AssetSortField = "valuation" | "holders" | "created_at";
export type SortDirection = "asc" | "desc";

export type Asset = {
id: number;
token_contract: string;
issuer: string;
name: string;
symbol: string;
asset_type: "real_estate" | "invoice" | "commodity";
description: string;
valuation_cents: string;
valuation_usd: number;
decimals: number;
total_supply: string;
holders: number;
active: boolean;
paused: boolean;
compliance_contract: string;
created_at_ledger: number;
};

export type Holder = {
address: string;
balance: string;
share_percent: number;
};

export type ComplianceSummary = {
total_records: number;
approved: number;
suspended: number;
rejected: number;
pending: number;
with_expiry: number;
jurisdictions: JurisdictionCount[];
};

export type JurisdictionCount = {
jurisdiction: string;
count: number;
};

export type Distribution = {
id: number;
asset_token: string;
payment_token: string;
total_amount: string;
distributed: string;
claimed_percent: number;
overflow_detected: boolean;
completed: boolean;
created_at_ledger: number;
};

export type Stats = {
total_assets: number;
active_assets: number;
tvl_cents: string;
tvl_usd: number;
total_holders: number;
total_distributions: number;
last_indexed_ledger: number;
last_updated: string;
};

export type Error = {
error: string;
message: string;
};

3 changes: 3 additions & 0 deletions docs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@
"private": true,
"description": "Documentation site for the Stellar RWA platform — contracts, API and web app.",
"scripts": {
"generate:api-client": "node scripts/generate-openapi-client.mjs",
"predev": "npm run generate:api-client",
"dev": "next dev",
"prebuild": "npm run generate:api-client",
"build": "next build",
"start": "next start",
"lint": "next lint && eslint . --ext .tsx,.ts --plugin jsx-a11y",
Expand Down
27 changes: 27 additions & 0 deletions docs/public/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,33 @@
"maximum": 100,
"default": 50
}
},
{
"name": "sort",
"in": "query",
"description": "Sort results by valuation, holder count, or creation ledger.",
"schema": {
"type": "string",
"enum": [
"valuation",
"holders",
"created_at"
],
"default": "valuation"
}
},
{
"name": "order",
"in": "query",
"description": "Sort direction applied to the selected sort field.",
"schema": {
"type": "string",
"enum": [
"asc",
"desc"
],
"default": "desc"
}
}
],
"responses": {
Expand Down
59 changes: 59 additions & 0 deletions docs/scripts/generate-openapi-client.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const rootDir = path.resolve(__dirname, '..');
const openapiPath = path.join(rootDir, 'public/openapi.json');
const outputPath = path.join(rootDir, 'lib/api-client.ts');

const spec = JSON.parse(fs.readFileSync(openapiPath, 'utf8'));
const schemas = spec.components?.schemas ?? {};
const names = ['Asset', 'Holder', 'ComplianceSummary', 'JurisdictionCount', 'Distribution', 'Stats', 'Error'];

const toTsType = (schema, seen = new Set()) => {
if (!schema) return 'unknown';
if (schema.$ref) {
const ref = schema.$ref.split('/').pop();
if (ref && !seen.has(ref)) return ref;
return 'unknown';
}
if (schema.enum) {
return schema.enum.map((value) => JSON.stringify(String(value))).join(' | ');
}
if (schema.type === 'array') {
return `${toTsType(schema.items, seen)}[]`;
}
if (schema.type === 'object' || schema.properties) {
return 'Record<string, unknown>';
}
if (schema.type === 'boolean') return 'boolean';
if (schema.type === 'integer' || schema.type === 'number') return 'number';
if (schema.type === 'string') return 'string';
return 'unknown';
};

const lines = [
'export type AssetSortField = "valuation" | "holders" | "created_at";',
'export type SortDirection = "asc" | "desc";',
'',
];

for (const name of names) {
const schema = schemas[name];
if (!schema || !schema.properties) continue;

const required = new Set(schema.required ?? []);
lines.push(`export type ${name} = {`);
for (const [key, value] of Object.entries(schema.properties)) {
const requiredSuffix = required.has(key) ? '' : '?';
const type = toTsType(value);
lines.push(` ${key}${requiredSuffix}: ${type};`);
}
lines.push('};');
lines.push('');
}

fs.writeFileSync(outputPath, `${lines.join('\n')}\n`, 'utf8');
console.log(`Generated ${path.relative(rootDir, outputPath)}`);