Skip to content
Draft
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
9 changes: 7 additions & 2 deletions .github/workflows/api_openapi_sync.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
name: API OpenAPI Sync
# This workflow runs weekly to check if the OpenAPI specifications need to be converted
# and ensures the generated code is up to date. It processes algod and indexer in parallel.
# and ensures the generated code is up to date. It processes algod, indexer, and kmd in parallel.

on:
schedule:
Expand All @@ -16,7 +16,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
spec: [algod, indexer]
spec: [algod, indexer, kmd]
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/setup-api-tools
Expand All @@ -27,6 +27,11 @@ jobs:
- name: Convert ${{ matrix.spec }} OpenAPI specification
run: cargo api convert-${{ matrix.spec }}

- name: Validate Transaction Types
run: |
cd api
bun run validate-tx-types

- name: Check for ${{ matrix.spec }} changes
run: |
git status --porcelain > /tmp/post_${{ matrix.spec }}_status.txt
Expand Down
19 changes: 19 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ members = [
"crates/ffi_macros",
"crates/algod_client",
"crates/indexer_client",
"crates/kmd_client",
"tools/build_pkgs",
"crates/uniffi-bindgen",
"docs",
Expand Down
18 changes: 18 additions & 0 deletions api/oas_generator/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,24 @@

The Rust OAS Generator is a Jinja2-based code generator that converts OpenAPI 3.x specifications into Rust API clients. The architecture emphasizes separation of concerns between parsing, analysis, and generation phases.

### Primitive Response Handling Pattern (String Early Return)

For endpoints whose success response body is a raw `String` (i.e., no structured JSON/model deserialization required), the generator emits an early return immediately after UTF-8 validation of the response body. The generic content negotiation / deserialization block is conditionally omitted for these endpoints. This avoids generating unreachable code (previously the template placed the early `return` above a still-rendered negotiation block) and yields simpler, smaller endpoint functions.

Guidelines:
- Condition: `success_type == "String"` triggers early-return pattern.
- Behavior: `String::from_utf8(response.body)` is performed, mapping any UTF-8 error to an internal `Error::Serde` variant, then `Ok(decoded_string)` is returned.
- No side-effects: Logging, metrics, or header-driven negotiation logic are skipped because they would have been no-ops for raw string passthrough semantics.
- Other primitive/raw types (e.g., potential future `Vec<u8>`) currently still flow through the standard content negotiation path; they may be optimized similarly if patterns of unreachable code emerge.

Rationale:
- Eliminates unreachable code warnings surfaced by Clippy (`unreachable_code`).
- Ensures generated code remains warning-free under `-D warnings` policy.
- Maintains behavioral parity: Non-`String` endpoints continue through negotiation logic untouched.

Future Extension:
- Introduce analogous conditional blocks for other opaque passthrough response types (e.g., raw bytes) when specifications require them, ensuring consistent early-return semantics without redundant negotiation scaffolding.

## Core Components

### 1. CLI Interface (`cli.py`)
Expand Down
7 changes: 6 additions & 1 deletion api/oas_generator/rust_oas_generator/generator/filters.py
Original file line number Diff line number Diff line change
Expand Up @@ -264,13 +264,15 @@ def detect_client_type(spec_title: str) -> str:
spec_title: The title field from the OpenAPI spec info section.

Returns:
The appropriate client type string (e.g., "Algod", "Indexer").
The appropriate client type string (e.g., "Algod", "Indexer", "Kmd").

Examples:
>>> detect_client_type("Algod REST API.")
'Algod'
>>> detect_client_type("Indexer")
'Indexer'
>>> detect_client_type("for KMD HTTP API")
'Kmd'
>>> detect_client_type("Unknown API")
'Api'
"""
Expand All @@ -284,6 +286,9 @@ def detect_client_type(spec_title: str) -> str:
return "Algod"
if "indexer" in title_lower:
return "Indexer"
# KMD titles often contain 'kmd' or phrases like 'for KMD HTTP API'
if "kmd" in title_lower or re.search(r"\bfor\s+kmd\b", title_lower):
return "Kmd"

# Fallback: extract first word and capitalize
first_word = spec_title.split()[0] if spec_title.split() else "Api"
Expand Down
5 changes: 5 additions & 0 deletions api/oas_generator/rust_oas_generator/parser/oas_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
"integer": {
None: "u64",
"int32": "u32",
"uint32": "u32",
"int64": "u64",
"uint64": "u64",
},
Expand Down Expand Up @@ -444,6 +445,7 @@ class Schema:
# For string enum schemas
enum_values: list[str] = field(default_factory=list)
is_string_enum: bool = field(init=False)
is_primitive_string_schema: bool = field(init=False, default=False)

def __post_init__(self) -> None:
# Keep the original struct name without renaming
Expand All @@ -456,6 +458,9 @@ def __post_init__(self) -> None:
self.has_required_fields = len(self.required_fields) > 0
self.has_signed_transaction_fields = any(prop.is_signed_transaction for prop in self.properties)
self.is_string_enum = self.schema_type == "string" and len(self.enum_values) > 0
# Mark primitive string schemas (referenced string without enum) for alias generation later
if self.schema_type == "string" and len(self.enum_values) == 0 and len(self.properties) == 0:
self.is_primitive_string_schema = True


@dataclass
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ impl {{ client_type }}Client {
Self { http_client }
}

{% if client_type != "Kmd" %}
/// Create a new {{ client_type }}Client for Algorand TestNet.
#[cfg(feature = "default_client")]
pub fn testnet() -> Self {
Expand All @@ -74,13 +75,14 @@ impl {{ client_type }}Client {
));
Self::new(http_client)
}
{% endif %}

/// Create a new {{ client_type }}Client for a local localnet environment.
#[cfg(feature = "default_client")]
pub fn localnet() -> Self {
let http_client = Arc::new(DefaultHttpClient::with_header(
{% if client_type == "Indexer" %}"http://localhost:8980"{% else %}"http://localhost:4001"{% endif %},
{% if client_type == "Indexer" %}"X-Indexer-API-Token"{% else %}"X-Algo-API-Token"{% endif %},
{% if client_type == "Indexer" %}"http://localhost:8980"{% elif client_type == "Kmd" %}"http://localhost:4002"{% else %}"http://localhost:4001"{% endif %},
{% if client_type == "Indexer" %}"X-Indexer-API-Token"{% elif client_type == "Kmd" %}"X-KMD-API-Token"{% else %}"X-Algo-API-Token"{% endif %},
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
).expect("Failed to create HTTP client with API token header"));
Self::new(http_client)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,14 @@ pub async fn {{ operation.rust_function_name }}(
{% endif %}
{% endfor %}

{# General rule: only set Content-Type when body exists; always set Accept #}
{% if operation.method == "GET" and not has_request_body(operation) %}
let mut headers: HashMap<String, String> = HashMap::new();
headers.insert("Accept".to_string(), "application/json".to_string());

let body = None;

{% else %}
{% if operation.request_body_supports_text_plain %}
let mut headers: HashMap<String, String> = HashMap::new();
headers.insert("Content-Type".to_string(), "text/plain".to_string());
Expand Down Expand Up @@ -175,6 +183,7 @@ pub async fn {{ operation.rust_function_name }}(
None
{% endif %};
{% endif %}
{% endif %}

let response = http_client
.request(
Expand All @@ -188,6 +197,10 @@ pub async fn {{ operation.rust_function_name }}(
.map_err(|e| Error::Http { source: e })?;

{% if get_success_response_type(operation) %}
{% set success_type = get_success_response_type(operation) %}
{% if success_type == "String" %}
String::from_utf8(response.body).map_err(|e| Error::Serde { message: e.to_string() })
{% else %}
let content_type = response
.headers
.get("content-type")
Expand All @@ -207,6 +220,7 @@ pub async fn {{ operation.rust_function_name }}(
},
ContentType::Unsupported(ct) => Err(Error::Serde { message: format!("Unsupported content type: {}", ct) }),
}
{% endif %}
{% else %}
let _ = response;
Ok(())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,4 +43,74 @@ impl {{ enum_name }} {
{{ enum_name }}::default()
}
}
{% endmacro %}
{% endmacro %}

{# Macro to generate a Rust enum for string enum values with Unknown(String) fallback #}
{% macro generate_string_enum_with_unknown(enum_name, enum_values, description=None) %}
{% if description %}
{{ description | rust_doc_comment }}
{% endif %}
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub enum {{ enum_name }} {
{% for value in enum_values %}
{{ value | pascal_case }},
{% endfor %}
/// Fallback for any future/unknown value encountered during deserialization.
Unknown(String),
}

impl {{ enum_name }} {
pub fn as_str(&self) -> &str {
match self {
{% for value in enum_values %}
{{ enum_name }}::{{ value | pascal_case }} => "{{ value }}",
{% endfor %}
{{ enum_name }}::Unknown(s) => s.as_str(),
}
}
}

impl std::fmt::Display for {{ enum_name }} {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.as_str()) }
}

impl Default for {{ enum_name }} { fn default() -> Self { {{ enum_name }}::Unknown(String::new()) } }

impl AsRef<str> for {{ enum_name }} { fn as_ref(&self) -> &str { self.as_str() } }

impl From<&str> for {{ enum_name }} { fn from(s: &str) -> Self { {{ enum_name }}::from(s.to_string()) } }
impl From<String> for {{ enum_name }} {
fn from(s: String) -> Self {
match s.as_str() {
{% for value in enum_values %}
"{{ value }}" => {{ enum_name }}::{{ value | pascal_case }},
{% endfor %}
_ => {{ enum_name }}::Unknown(s),
}
}
}

impl std::str::FromStr for {{ enum_name }} {
type Err = std::convert::Infallible;
fn from_str(s: &str) -> Result<Self, Self::Err> { Ok({{ enum_name }}::from(s)) }
}

impl serde::Serialize for {{ enum_name }} {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> where S: serde::ser::Serializer {
serializer.serialize_str(self.as_str())
}
}

struct {{ enum_name }}Visitor;
impl<'de> serde::de::Visitor<'de> for {{ enum_name }}Visitor {
type Value = {{ enum_name }};
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { write!(f, "a string enum value") }
fn visit_str<E>(self, v: &str) -> Result<Self::Value, E> where E: serde::de::Error { Ok({{ enum_name }}::from(v)) }
}

impl<'de> serde::Deserialize<'de> for {{ enum_name }} {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> where D: serde::de::Deserializer<'de> {
deserializer.deserialize_str({{ enum_name }}Visitor)
}
}
{% endmacro %}
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ use crate::models::{{ custom_type }};
{% if schema.schema_type == 'array' and schema.underlying_rust_type %}
{% if schema.underlying_rust_type.startswith('Vec<') %}
{% set alias_inner = schema.underlying_rust_type[4:-1] %}
{% if alias_inner and alias_inner[0].isupper() and '::' not in alias_inner %}
{% if alias_inner and alias_inner[0].isupper() and '::' not in alias_inner and alias_inner not in ['String', 'Vec', 'Option', 'serde_json::Value'] and not alias_inner.startswith('i') and not alias_inner.startswith('u') and alias_inner != 'bool' and alias_inner != 'Vec<u8>' %}
use crate::models::{{ alias_inner }};
{% endif %}
{% endif %}
Expand All @@ -69,7 +69,26 @@ use crate::models::{{ alias_inner }};
pub type {{ schema.rust_struct_name }} = {{ schema.underlying_rust_type }};
{# Handle string enum schemas as Rust enums #}
{% elif schema.is_string_enum %}
{# If the schema declares unknown-fallback support via vendor extension, generate with Unknown #}
{% if schema.vendor_extensions.get('x-algokit-unknown-fallback') %}
{% from 'macros/enum_macros.j2' import generate_string_enum_with_unknown %}
{{ generate_string_enum_with_unknown(schema.rust_struct_name, schema.enum_values, schema.description) }}
{% else %}
{{ generate_string_enum(schema.rust_struct_name, schema.enum_values, schema.description) }}
{% endif %}
{# Handle primitive string schemas (referenced string without enum) as transparent newtypes #}
{% elif schema.is_primitive_string_schema %}
{% if schema.description %}
{{ schema.description | rust_doc_comment }}
{% endif %}
#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)]
pub struct {{ schema.rust_struct_name }}(pub String);
impl std::fmt::Display for {{ schema.rust_struct_name }} {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.0) }
}
impl From<String> for {{ schema.rust_struct_name }} { fn from(s: String) -> Self { Self(s) } }
impl From<&str> for {{ schema.rust_struct_name }} { fn from(s: &str) -> Self { Self(s.to_string()) } }
impl AsRef<str> for {{ schema.rust_struct_name }} { fn as_ref(&self) -> &str { &self.0 } }
{% else %}
{% if schema.description %}
{{ schema.description | rust_doc_comment }}
Expand Down Expand Up @@ -208,7 +227,7 @@ impl AlgorandMsgpack for {{ schema.rust_struct_name }} {
}
{% endif %}

{% if not (schema.schema_type == 'array' and schema.underlying_rust_type) and not schema.is_string_enum %}
{% if not (schema.schema_type == 'array' and schema.underlying_rust_type) and not schema.is_string_enum and schema.rust_struct_name != 'TxType' %}
impl {{ schema.rust_struct_name }} {
{% if schema.has_required_fields %}
/// Constructor for {{ schema.rust_struct_name }}
Expand Down
4 changes: 4 additions & 0 deletions api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,9 @@
},
"peerDependencies": {
"typescript": "^5"
},
"scripts": {
"validate-tx-types": "bun scripts/validate-transaction-types.ts",
"convert": "bun scripts/convert-openapi.ts && bun scripts/validate-transaction-types.ts"
}
}
Loading
Loading