Skip to content
Open
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
29 changes: 29 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,35 @@ The frontend communicates with the StableRoute API backend.
- **`/api/v1/events`**: Retrieves system event audit logs (`GET`).
- **`/api/v1/webhooks`**: Creates (`POST`), lists (`GET`), and revokes (`DELETE` at `/api/v1/webhooks/:id`) webhook subscriptions.

### API Client Contract

`src/lib/apiClient.ts` centralizes frontend calls to the backend through
`apiGet`, `apiPost`, `apiPatch`, and `apiDelete`, all layered on `apiFetch`.
Paths are resolved relative to `NEXT_PUBLIC_STABLEROUTE_API_BASE`; requests send
`Content-Type: application/json` by default, and JSON request bodies are
serialized by the method helpers.

The backend error envelope is represented by `ApiError`:

```ts
type ApiError = {
error: string;
message: string;
requestId?: string;
};
```

Failed responses reject with an `Error` whose message prefers
`ApiError.message`, whose `status` property carries the HTTP status, and whose
parsed error fields are copied onto the thrown object. `204 No Content`
resolves to `undefined`; non-empty successful responses must parse as JSON.

`registerAuthErrorHandler` stores one global auth callback for `401` and `403`
responses. Registering a new callback replaces the previous one, and the
returned unregister function removes it only if it is still active.
`ApiAuthGuard` mounts this handler inside `ToastProvider` so auth failures show
toasts while the original API call still rejects normally.

### Asset Codes

Stellar asset codes entered through the new-pair form are trimmed, validated as
Expand Down
13 changes: 11 additions & 2 deletions src/app/quote/Client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import { useState } from "react";
import type { ApiError } from "@/lib/apiClient";
import { assetsDiffer, isValidAmount } from "@/lib/quote";

type Quote = {
source_asset: string;
Expand Down Expand Up @@ -38,11 +39,19 @@ export default function QuoteClient() {
setRequestId(null);
setQuote(null);

if (!assetsDiffer(sourceAsset, destAsset)) {
const normalizedSourceAsset = normalizeAssetCode(sourceAsset);
const normalizedDestAsset = normalizeAssetCode(destAsset);
const normalizedAmount = amount.trim();

if (!normalizedSourceAsset || !normalizedDestAsset) {
setError("Asset codes must be 1-12 letters or numbers.");
return;
}
if (!assetsDiffer(normalizedSourceAsset, normalizedDestAsset)) {
setError("Source and destination assets must differ.");
return;
}
if (!isValidAmount(amount)) {
if (!isValidAmount(normalizedAmount)) {
setError("Amount must be a positive integer (base units).");
return;
}
Expand Down
36 changes: 35 additions & 1 deletion src/lib/apiClient.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
const API_BASE =
process.env.NEXT_PUBLIC_STABLEROUTE_API_BASE ?? "http://localhost:3001";

/**
* Canonical JSON error envelope returned by the StableRoute backend.
*
* `requestId` is optional because network failures and some legacy responses
* may not include the backend correlation id.
*/
export type ApiError = {
error: string;
message: string;
Expand All @@ -10,14 +16,38 @@ export type ApiError = {
type AuthErrorHandler = (status: 401 | 403) => void;
let _authErrorHandler: AuthErrorHandler | null = null;

/** Called once by <ApiAuthGuard> when it mounts inside <ToastProvider>. */
/**
* Register the single global auth-error handler.
*
* The latest registration replaces any previous handler. `ApiAuthGuard` calls
* this while mounted inside `ToastProvider`, then calls the returned unregister
* function on unmount. The guard is notified for backend `401` and `403`
* responses, but the original request still rejects normally.
*
* @param handler - Callback invoked with the auth failure status.
* @returns A cleanup function that unregisters `handler` if it is still active.
*/
export function registerAuthErrorHandler(handler: AuthErrorHandler): () => void {
_authErrorHandler = handler;
return () => {
if (_authErrorHandler === handler) _authErrorHandler = null;
};
}

/**
* Fetch JSON from the StableRoute API.
*
* Requests are made relative to `NEXT_PUBLIC_STABLEROUTE_API_BASE` and include
* `Content-Type: application/json` by default. `204 No Content` resolves to
* `undefined`. Non-empty successful responses must be valid JSON. Failed
* responses reject with an `Error` whose message comes from the backend
* `ApiError.message` when present, with `status` and any parsed error fields
* attached to the thrown object.
*
* @param path - Backend path beginning with `/`.
* @param init - Optional fetch init merged with the default JSON header.
* @returns The parsed response body typed as `T`.
*/
export async function apiFetch<T>(
path: string,
init: RequestInit = {}
Expand Down Expand Up @@ -48,10 +78,14 @@ export async function apiFetch<T>(
return body as T;
}

/** GET a JSON resource from the StableRoute API. */
export const apiGet = <T>(path: string) => apiFetch<T>(path);
/** POST a JSON body and parse the JSON response. */
export const apiPost = <T>(path: string, body: unknown) =>
apiFetch<T>(path, { method: "POST", body: JSON.stringify(body) });
/** PATCH a JSON body and parse the JSON response. */
export const apiPatch = <T>(path: string, body: unknown) =>
apiFetch<T>(path, { method: "PATCH", body: JSON.stringify(body) });
/** DELETE a resource, resolving to `undefined` for a 204 response. */
export const apiDelete = (path: string) =>
apiFetch<void>(path, { method: "DELETE" });