Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
2763110
chore: wip network, client, asset manager tweaks
aorumbayev Oct 10, 2025
28f8dc0
chore: wip
aorumbayev Oct 10, 2025
7303b97
feat(ts): batch asset bulk opt operations
aorumbayev Oct 14, 2025
c6bb808
feat(rust): batch asset bulk operations
aorumbayev Oct 14, 2025
3647094
refactor: expand supported network aliases in network client in rs an…
aorumbayev Oct 14, 2025
ddf31d5
feat: add default retryable http client in ts (adopted from legacy ut…
aorumbayev Oct 14, 2025
2a81f84
chore: prettier
aorumbayev Oct 14, 2025
8ef33c2
fix: bult opt out rust test
aorumbayev Oct 14, 2025
7f2722d
chore: revert algorand changes (out of pr scope)
aorumbayev Oct 14, 2025
a8d0196
chore: typo
aorumbayev Oct 14, 2025
63f20aa
Merge remote-tracking branch 'origin/main' into feat/ts-client-asset-…
aorumbayev Oct 15, 2025
eef3f86
chore: tests wip
aorumbayev Oct 16, 2025
265b8c1
chore: consolidating test helpers
aorumbayev Oct 16, 2025
6c2cfef
chore: consolidate lint and format scripts; ensure all packages run l…
aorumbayev Oct 16, 2025
4c70c52
chore: further removal of dependencies on algosdk
aorumbayev Oct 16, 2025
d9d4fa6
chore: pr comments
aorumbayev Oct 17, 2025
a1061fd
refactor: pr comments
aorumbayev Oct 17, 2025
ca68e91
chore: adding retry to default fetch client; minor clippy tweaks
aorumbayev Oct 17, 2025
4660ceb
Merge remote-tracking branch 'origin/main' into feat/ts-client-asset-…
aorumbayev Oct 24, 2025
0425f83
fix: limit account information endpoint to json only
aorumbayev Oct 24, 2025
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
7 changes: 2 additions & 5 deletions .github/workflows/api_ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -129,13 +129,10 @@ jobs:
working-directory: packages/typescript
run: npm run lint --workspace ${{ matrix.workspace }}

- name: Build all TypeScript packages
- name: Build workspace
working-directory: packages/typescript
run: |
npm run build --workspace @algorandfoundation/algokit-common
npm run build --workspace @algorandfoundation/algokit-abi
npm run build --workspace @algorandfoundation/algokit-transact
npm run build --workspace ${{ matrix.workspace }}
npm run build

- name: Update package links after build
working-directory: packages/typescript
Expand Down
4 changes: 2 additions & 2 deletions api/oas_generator/rust_oas_generator/parser/oas_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -787,12 +787,12 @@ def _parse_parameter(self, param_data: dict[str, Any]) -> Parameter | None:
if not name:
return None

# Skip `format` query parameter when constrained to msgpack only
# Skip `format` query parameter when constrained to a single format (json or msgpack)
in_location = param_data.get("in", "query")
if name == "format" and in_location == "query":
schema_obj = param_data.get("schema", {}) or {}
enum_vals = schema_obj.get("enum")
if isinstance(enum_vals, list) and len(enum_vals) == 1 and enum_vals[0] == "msgpack":
if isinstance(enum_vals, list) and len(enum_vals) == 1 and enum_vals[0] in ("msgpack", "json"):
return None

schema = param_data.get("schema", {})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -385,13 +385,13 @@ def _process_parameters(self, params: list[Schema], spec: Schema) -> list[Parame

# Extract parameter details
raw_name = str(param.get("name"))
# Skip `format` query param when it's constrained to only msgpack
# Skip `format` query param when it's constrained to a single format (json or msgpack)
location_candidate = param.get(constants.OperationKey.IN, constants.ParamLocation.QUERY)
if location_candidate == constants.ParamLocation.QUERY and raw_name == constants.FORMAT_PARAM_NAME:
schema_obj = param.get("schema", {}) or {}
enum_vals = schema_obj.get(constants.SchemaKey.ENUM)
if isinstance(enum_vals, list) and len(enum_vals) == 1 and enum_vals[0] == "msgpack":
# Endpoint only supports msgpack; do not expose/append `format`
if isinstance(enum_vals, list) and len(enum_vals) == 1 and enum_vals[0] in ("msgpack", "json"):
# Endpoint only supports a single format; do not expose/append `format`
continue
var_name = self._sanitize_variable_name(ts_camel_case(raw_name), used_names)
used_names.add(var_name)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,12 @@ export interface ClientConfig {
password?: string;
headers?: Record<string, string> | (() => Record<string, string> | Promise<Record<string, string>>);
encodePath?: (path: string) => string;
/** Optional override for retry attempts; values <= 1 disable retries. This is the canonical field. */
maxRetries?: number;
/** Optional cap on exponential backoff delay in milliseconds. */
maxBackoffMs?: number;
/** Optional list of HTTP status codes that should trigger a retry. */
retryStatusCodes?: number[];
/** Optional list of Node.js/System error codes that should trigger a retry. */
retryErrorCodes?: string[];
}

Original file line number Diff line number Diff line change
@@ -1,8 +1,120 @@
import { BaseHttpRequest, type ApiRequestOptions } from './base-http-request';
import { request } from './request';

const RETRY_STATUS_CODES = [408, 413, 429, 500, 502, 503, 504];
const RETRY_ERROR_CODES = [
'ETIMEDOUT',
'ECONNRESET',
'EADDRINUSE',
'ECONNREFUSED',
'EPIPE',
'ENOTFOUND',
'ENETUNREACH',
'EAI_AGAIN',
'EPROTO',
];

const DEFAULT_MAX_TRIES = 5;
const DEFAULT_MAX_BACKOFF_MS = 10_000;

const toNumber = (value: unknown): number | undefined => {
if (typeof value === 'number') {
return Number.isNaN(value) ? undefined : value;
}
if (typeof value === 'string') {
const parsed = Number(value);
return Number.isNaN(parsed) ? undefined : parsed;
}
return undefined;
};

const extractStatus = (error: unknown): number | undefined => {
if (!error || typeof error !== 'object') {
return undefined;
}
const candidate = error as { status?: unknown; response?: { status?: unknown } };
return toNumber(candidate.status ?? candidate.response?.status);
};

const extractCode = (error: unknown): string | undefined => {
if (!error || typeof error !== 'object') {
return undefined;
}
const candidate = error as { code?: unknown; cause?: { code?: unknown } };
const raw = candidate.code ?? candidate.cause?.code;
return typeof raw === 'string' ? raw : undefined;
};

const delay = async (ms: number): Promise<void> =>
new Promise((resolve) => {
setTimeout(resolve, ms);
});

const normalizeTries = (maxRetries?: number): number => {
const candidate = maxRetries;
if (typeof candidate !== 'number' || !Number.isFinite(candidate)) {
return DEFAULT_MAX_TRIES;
}
const rounded = Math.floor(candidate);
return rounded <= 1 ? 1 : rounded;
};

const normalizeBackoff = (maxBackoffMs?: number): number => {
if (typeof maxBackoffMs !== 'number' || !Number.isFinite(maxBackoffMs)) {
return DEFAULT_MAX_BACKOFF_MS;
}
const normalized = Math.floor(maxBackoffMs);
return normalized <= 0 ? 0 : normalized;
};

export class FetchHttpRequest extends BaseHttpRequest {
async request<T>(options: ApiRequestOptions): Promise<T> {
return request(this.config, options);
const maxTries = normalizeTries(this.config.maxRetries);
const maxBackoffMs = normalizeBackoff(this.config.maxBackoffMs);

let attempt = 1;
let lastError: unknown;
while (attempt <= maxTries) {
try {
return await request(this.config, options);
} catch (error) {
lastError = error;
if (!this.shouldRetry(error, attempt, maxTries)) {
throw error;
}

const backoff = attempt === 1 ? 0 : Math.min(1000 * 2 ** (attempt - 1), maxBackoffMs);
if (backoff > 0) {
await delay(backoff);
}
attempt += 1;
}
}

throw lastError ?? new Error(`Request failed after ${maxTries} attempt(s)`)
}

private shouldRetry(error: unknown, attempt: number, maxTries: number): boolean {
if (attempt >= maxTries) {
return false;
}

const status = extractStatus(error);
if (status !== undefined) {
const retryStatuses = this.config.retryStatusCodes ?? RETRY_STATUS_CODES;
if (retryStatuses.includes(status)) {
return true;
}
}

const code = extractCode(error);
if (code) {
const retryCodes = this.config.retryErrorCodes ?? RETRY_ERROR_CODES;
if (retryCodes.includes(code)) {
return true;
}
}

return false;
}
}
89 changes: 52 additions & 37 deletions api/scripts/convert-openapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ interface FieldTransform {
addItems?: Record<string, any>; // properties to add to the target property, e.g., {"x-custom": true}
}

interface MsgpackOnlyEndpoint {
interface FilterEndpoint {
path: string; // Exact path to match (e.g., "/v2/blocks/{round}")
methods?: string[]; // HTTP methods to apply to (default: ["get"])
}
Expand All @@ -54,7 +54,8 @@ interface ProcessingConfig {
vendorExtensionTransforms?: VendorExtensionTransform[];
requiredFieldTransforms?: RequiredFieldTransform[];
fieldTransforms?: FieldTransform[];
msgpackOnlyEndpoints?: MsgpackOnlyEndpoint[];
msgpackOnlyEndpoints?: FilterEndpoint[];
jsonOnlyEndpoints?: FilterEndpoint[];
// If true, strip APIVn prefixes from component schemas and update refs (KMD)
stripKmdApiVersionPrefixes?: boolean;
}
Expand Down Expand Up @@ -480,18 +481,18 @@ function transformRequiredFields(spec: OpenAPISpec, requiredFieldTransforms: Req
}

/**
* Enforce msgpack-only format for specific endpoints by removing JSON support
*
* This function modifies endpoints to only support msgpack format, aligning with
* Go and JavaScript SDK implementations that hardcode these endpoints to msgpack.
* Enforce a single endpoint format (json or msgpack) by stripping the opposite one
*/
function enforceMsgpackOnlyEndpoints(spec: OpenAPISpec, endpoints: MsgpackOnlyEndpoint[]): number {
function enforceEndpointFormat(spec: OpenAPISpec, endpoints: FilterEndpoint[], targetFormat: "json" | "msgpack"): number {
let modifiedCount = 0;

if (!spec.paths || !endpoints?.length) {
return modifiedCount;
}

const targetContentType = targetFormat === "json" ? "application/json" : "application/msgpack";
const otherContentType = targetFormat === "json" ? "application/msgpack" : "application/json";

for (const endpoint of endpoints) {
const pathObj = spec.paths[endpoint.path];
if (!pathObj) {
Expand All @@ -507,54 +508,58 @@ function enforceMsgpackOnlyEndpoints(spec: OpenAPISpec, endpoints: MsgpackOnlyEn
continue;
}

// Look for format parameter in query parameters
// Query parameter: format
if (operation.parameters && Array.isArray(operation.parameters)) {
for (const param of operation.parameters) {
// Handle both inline parameters and $ref parameters
const paramObj = param.$ref ? resolveRef(spec, param.$ref) : param;

if (paramObj && paramObj.name === "format" && paramObj.in === "query") {
// OpenAPI 3.0 has schema property containing the type information
const schemaObj = paramObj.schema || paramObj;

// Check if it has an enum with both json and msgpack
if (schemaObj.enum && Array.isArray(schemaObj.enum)) {
if (schemaObj.enum.includes("json") && schemaObj.enum.includes("msgpack")) {
// Remove json from enum, keep only msgpack
schemaObj.enum = ["msgpack"];
// Update default if it was json
if (schemaObj.default === "json") {
schemaObj.default = "msgpack";
const values: string[] = schemaObj.enum;
if (values.includes("json") || values.includes("msgpack")) {
if (values.length !== 1 || values[0] !== targetFormat) {
schemaObj.enum = [targetFormat];
if (schemaObj.default !== targetFormat) schemaObj.default = targetFormat;
modifiedCount++;
console.log(`ℹ️ Enforced ${targetFormat}-only for ${endpoint.path} (${method}) parameter`);
}
// Don't modify the description - preserve original documentation
modifiedCount++;
console.log(`ℹ️ Enforced msgpack-only for ${endpoint.path} (${method}) parameter`);
}
} else if (schemaObj.type === "string" && !schemaObj.enum) {
// If no enum is specified, add one with only msgpack
schemaObj.enum = ["msgpack"];
schemaObj.default = "msgpack";
// Don't modify the description - preserve original documentation
schemaObj.enum = [targetFormat];
schemaObj.default = targetFormat;
modifiedCount++;
console.log(`ℹ️ Enforced msgpack-only for ${endpoint.path} (${method}) parameter`);
console.log(`ℹ️ Enforced ${targetFormat}-only for ${endpoint.path} (${method}) parameter`);
}
}
}
}

// Also check for format in response content types
// Request body content types
if (operation.requestBody && typeof operation.requestBody === "object") {
const rbRaw: any = operation.requestBody;
const rb: any = rbRaw.$ref ? resolveRef(spec, rbRaw.$ref) || rbRaw : rbRaw;
if (rb && rb.content && rb.content[otherContentType] && rb.content[targetContentType]) {
delete rb.content[otherContentType];
modifiedCount++;
console.log(`ℹ️ Removed ${otherContentType} request content-type for ${endpoint.path} (${method})`);
}
}

// Response content types
if (operation.responses) {
for (const [statusCode, response] of Object.entries(operation.responses)) {
if (response && typeof response === "object") {
const responseObj = response as any;

// If response has content with both json and msgpack, remove json
if (responseObj.content) {
if (responseObj.content["application/json"] && responseObj.content["application/msgpack"]) {
delete responseObj.content["application/json"];
modifiedCount++;
console.log(`ℹ️ Removed JSON response content-type for ${endpoint.path} (${method}) - ${statusCode}`);
}
const responseTarget: any = responseObj.$ref ? resolveRef(spec, responseObj.$ref) || responseObj : responseObj;
if (
responseTarget &&
responseTarget.content &&
responseTarget.content[otherContentType] &&
responseTarget.content[targetContentType]
) {
delete responseTarget.content[otherContentType];
modifiedCount++;
console.log(`ℹ️ Removed ${otherContentType} response content-type for ${endpoint.path} (${method}) - ${statusCode}`);
}
}
}
Expand Down Expand Up @@ -832,10 +837,16 @@ class OpenAPIProcessor {

// 9. Enforce msgpack-only endpoints if configured
if (this.config.msgpackOnlyEndpoints && this.config.msgpackOnlyEndpoints.length > 0) {
const msgpackCount = enforceMsgpackOnlyEndpoints(spec, this.config.msgpackOnlyEndpoints);
const msgpackCount = enforceEndpointFormat(spec, this.config.msgpackOnlyEndpoints, "msgpack");
console.log(`ℹ️ Enforced msgpack-only format for ${msgpackCount} endpoint parameters/responses`);
}

// 10. Enforce json-only endpoints if configured
if (this.config.jsonOnlyEndpoints && this.config.jsonOnlyEndpoints.length > 0) {
const jsonCount = enforceEndpointFormat(spec, this.config.jsonOnlyEndpoints, "json");
console.log(`ℹ️ Enforced json-only format for ${jsonCount} endpoint parameters/responses`);
}

// Save the processed spec
await SwaggerParser.validate(JSON.parse(JSON.stringify(spec)));
console.log("✅ Specification is valid");
Expand Down Expand Up @@ -992,6 +1003,10 @@ async function processAlgodSpec() {
{ path: "/v2/deltas/txn/group/{id}", methods: ["get"] },
{ path: "/v2/deltas/{round}/txn/group", methods: ["get"] },
],
jsonOnlyEndpoints: [
{ path: "/v2/accounts/{address}", methods: ["get"] },
{ path: "/v2/accounts/{address}/assets/{asset-id}", methods: ["get"] },
],
};

await processAlgorandSpec(config);
Expand Down
Loading