This is a continuation of CotomyElement Value and Form Behavior . The previous article focused on what values are submitted from the DOM. This article focuses on how CotomyApi transports those values and how the screen should handle success and failure explicitly.
Why This Boundary Matters
In business screens, the API call is not a single line of transport code. It is one operation lifecycle from user intent to UI reflection. If this boundary is inconsistent, validation, conflict handling, and retry behavior drift screen by screen.
CotomyApi keeps transport behavior consistent, but it does not decide business meaning for you. The screen still owns UI state updates.
Full Request Lifecycle
sequenceDiagram
participant User
participant UI as CotomyElement
participant Form as CotomyForm
participant API as CotomyApi
participant Server
participant Response
User->>UI: Edit fields
UI->>Form: Submit intent
Form->>API: Build payload
API->>Server: HTTP request
Server-->>API: HTTP response
API-->>Form: Normalized response
Form-->>UI: Update screen state
This is the practical flow to preserve. CotomyApi returns a response object, but the final state transition is still an explicit UI decision.
Method Behavior in Real Screens
CotomyApi provides getAsync, postAsync, putAsync, patchAsync, deleteAsync, and submitAsync. The first five are direct method calls. submitAsync dispatches by form.method, and when the method is GET it routes to getAsync so parameters become a query string.
import { CotomyApi } from "cotomy";
type User = { id: string; name: string };
const api = new CotomyApi({ baseUrl: "/api" });
export async function loadUser(id: string): Promise<User> {
const response = await api.getAsync(`/users/${id}`);
return await response.objectAsync<User>();
}
export async function createUser(name: string): Promise<User> {
const response = await api.postAsync("/users", { name });
return await response.objectAsync<User>();
}
export async function updateUser(id: string, name: string): Promise<User> {
const response = await api.putAsync(`/users/${id}`, { name });
return await response.objectAsync<User>();
}
export async function patchUser(id: string, name: string): Promise<User> {
const response = await api.patchAsync(`/users/${id}`, { name });
return await response.objectAsync<User>();
}
export async function removeUser(id: string): Promise<void> {
await api.deleteAsync(`/users/${id}`);
}
Options and Default Payload Behavior
CotomyApi options include baseUrl, headers, credentials, redirect, cache, referrerPolicy, mode, keepalive, and integrity. Defaults are same-origin credentials, follow redirect, no-cache, no-referrer, cors mode, and keepalive true.
For request bodies, the behavior is content-type driven. When Content-Type is application/json, the body is JSON stringified. When Content-Type is application/x-www-form-urlencoded, the body is converted with URLSearchParams. Otherwise the internal default is multipart/form-data. In that default path, a plain object is converted to FormData and sent as multipart. If multipart/form-data is used and the body is neither FormData nor a plain object, CotomyInvalidFormDataBodyException is thrown before fetch.
Exception Mapping and Screen-Level Branching
HTTP 4xx and 5xx responses are mapped to Cotomy exceptions. 400 and 422 map to CotomyRequestInvalidException. 401 maps to CotomyUnauthorizedException. 403 maps to CotomyForbiddenException. 404 maps to CotomyNotFoundException. 409 and 410 map to CotomyConflictException. 429 maps to CotomyTooManyRequestsException. Other 4xx map to CotomyHttpClientError, and 5xx map to CotomyHttpServerError.
flowchart TD
A[Submit Intent] --> B[CotomyApi Request]
B --> C{HTTP Status}
C -->|2xx| D[Parse objectAsync / arrayAsync]
D --> E[Update UI Explicitly]
C -->|400 / 422| F[CotomyRequestInvalidException]
F --> G[Show Validation Guidance]
C -->|401| H[CotomyUnauthorizedException]
H --> I[Trigger Auth Flow]
C -->|409 / 410| J[CotomyConflictException]
J --> K[Show Conflict Message]
C -->|5xx| L[CotomyHttpServerError]
L --> M[Show Retry Guidance]
C -->|Network Failure| N[Native Runtime Error]
N --> O[Show Generic Failure]
The network failure branch is separate. If fetch fails before an HTTP response exists, CotomyApi does not wrap it as CotomyApiException. You receive the native runtime error.
import {
CotomyApi,
CotomyConflictException,
CotomyHttpServerError,
CotomyRequestInvalidException,
CotomyResponseJsonParseException,
CotomyUnauthorizedException,
} from "cotomy";
const api = new CotomyApi();
export async function submitOrder(body: FormData): Promise<void> {
try {
const response = await api.postAsync("/api/orders", body);
const saved = await response.objectAsync<{ id: string; code: string }>();
renderSuccess(saved.code);
} catch (error) {
if (error instanceof CotomyRequestInvalidException) {
renderValidation();
return;
}
if (error instanceof CotomyUnauthorizedException) {
renderUnauthorized();
return;
}
if (error instanceof CotomyConflictException) {
renderConflict();
return;
}
if (error instanceof CotomyHttpServerError) {
renderRetry();
return;
}
if (error instanceof CotomyResponseJsonParseException) {
renderContractError();
return;
}
renderGenericFailure();
}
}
JSON Parse Failures
objectAsync and arrayAsync parse response text once and cache the parsed value. If JSON parsing fails, they throw CotomyResponseJsonParseException. This is a response contract failure, not a validation failure.
arrayAsync also has a guard behavior. If the parsed payload is not an array, it returns the provided default array value.
Key Collision Handling
There are two collision points to explain clearly.
On the request side, plain object keys are unique by JavaScript rules, so the last assignment wins before CotomyApi sends anything. If repeated keys are required, use FormData append so duplicate query or form keys are preserved.
const api = new CotomyApi();
const payload: any = { status: "draft" };
payload.status = "approved";
await api.postAsync("/api/orders", payload);
const tags = new FormData();
tags.append("tag", "a");
tags.append("tag", "b");
await api.getAsync("/api/search", tags);
On the response side, collisions happen when the screen merges server data into local state without a mapping boundary. CotomyApi returns parsed data, but it does not merge UI state for you. That merge policy belongs to the application layer.
type OrderViewModel = {
status: string;
amount: number;
};
const viewModel: OrderViewModel = { status: "", amount: 0 };
const localUiState = { expanded: false };
const response = await api.getAsync("/api/orders/42");
const server = await response.objectAsync<any>();
viewModel.status = String(server.status ?? "");
viewModel.amount = Number(server.amount ?? 0);
// localUiState stays separate and cannot be overwritten by API payload
Architectural Boundary to Keep
CotomyElement and CotomyForm handle UI and submit timing. CotomyApi handles HTTP transport and exception mapping. Domain decisions, conflict resolution policy, and final UI state remain in the application layer.
That boundary is what keeps the behavior explainable when the screen grows.
Usage Series
This article is part of the Cotomy Usage Series, which focuses on concrete runtime behavior and day-to-day API usage.
Series articles: CotomyElement in Practice , CotomyElement Value and Form Behavior , CotomyApi in Practice, and Debugging Features and Runtime Inspection in Cotomy .
Conclusion
CotomyApi is practical because it standardizes request conversion, exception mapping, and response parsing while leaving business decisions outside the transport layer. When the screen keeps explicit branching for success, validation, auth, conflict, server error, network error, and parse error, operational behavior stays stable across pages.
Previous article: CotomyElement Value and Form Behavior Next article: Debugging Features and Runtime Inspection in Cotomy