How do I make HTTP requests?
import { AppHttpClient, HttpClient, Service, path } from '../core/index.js';
export class UsersService extends Service<UsersService> {
constructor() { super(); }
public getUsers() {
return AppHttpClient.get<{ id: number; name: string }[]>('/api/users', {
queryParams: { page: 1 },
timeout: 5000,
});
}
public getUser(id: string) {
return AppHttpClient.get(path`/api/users/${id}`);
}
public createUser(payload: { name: string }) {
return AppHttpClient.post('/api/users', payload);
}
}
get<T = unknown>(endpoint: string, config?: Nutin.RequestConfig): Promise<T>;
post<T = unknown>(endpoint: string, data?: unknown, config?: Nutin.RequestConfig): Promise<T>;
put<T = unknown>(endpoint: string, data?: unknown, config?: Nutin.RequestConfig): Promise<T>;
patch<T = unknown>(endpoint: string, data?: unknown, config?: Nutin.RequestConfig): Promise<T>;
delete<T = unknown>(endpoint: string, config?: Nutin.RequestConfig): Promise<T>;
AppHttpClient is the framework's default HttpClient singleton, without a base URL: a relative endpoint (/api/users) is resolved against the page's origin, and an absolute one (https://…) is used as it is. Only http: and https: URLs are accepted.
// Nutin.RequestConfig
interface RequestConfig {
headers?: Record<string, string>;
queryParams?: Record<string, string | number | boolean | null | undefined | Array<…>>;
timeout?: number; // ms, > 0; throws Error('Request timed out') when exceeded
signal?: AbortSignal; // your own cancellation; its abort is rethrown as it is
credentials?: RequestCredentials;
cache?: RequestCache;
referrerPolicy?: ReferrerPolicy;
redirect?: RequestRedirect; // fetch's default: 'follow'
}
queryParams values are converted to strings; null/undefined ones are skipped, and an array repeats the key (tag=a&tag=b).
Request bodies
- A plain value (object, array, number, boolean, string) is sent as JSON, with
Content-Type: application/jsonunless you set another one.0,falseand''are sent too; onlynull/undefinedmean "no body". FormData,Blob,URLSearchParams,ArrayBuffer/typed arrays andReadableStreamare sent as they are, and the browser sets their content type (e.g. multipart boundaries forFormData).- Requests without a body (GET, DELETE) don't send a
Content-Type, so a plain cross-origin GET needs no CORS preflight.
Headers are merged case-insensitively: a per-call content-type replaces a default Content-Type.
Responses
application/jsonand any+jsontype (application/problem+json, …) are parsed.- A 204/205 or an empty body resolves to
undefined. - Anything else is returned as text, so the return type isn't guaranteed to match
Tfor non-JSON APIs. - A non-OK response throws
HttpError(exported fromcore/index):.status,.statusText, and.response, the parsed JSON error body ornull.
Base URL
A subclass can set a base URL, default headers and options:
class ApiClient extends HttpClient {
constructor() {
super('https://api.example.com/v1', { Authorization: `Bearer ${token}` });
}
}
ApiClient.getInstance().get(path`/users/${userId}`);
The endpoint is resolved against the base URL, and the request is rejected (an Error, before fetch) if the result isn't on the base URL's origin and under its path, so a value in the endpoint can't send your default headers to another host, or reach /admin through ../. A query string in the base URL (?version=2) is kept, ahead of the endpoint's own.
Without a base URL, default headers are only sent to the page's own origin. Headers you pass per call are sent wherever that call goes.
Redirects
Redirects are followed (fetch's default), but a redirect must end where the request was allowed to go: under the base URL, or, without one, on the origin you requested. Otherwise the call throws, and the other origin's response never reaches your code or your response interceptors.
Browsers remove Authorization when they follow a redirect to another origin, but they keep other headers. If your API authenticates with a custom header (X-Api-Key, …), a redirect to another origin would still send that header there once, before the client rejects the response. For such APIs, refuse redirects altogether:
api.get('/reports', { redirect: 'error' });
Values in paths: path
Build endpoints from values with the path tag. Each value is encoded as one path segment, and ./.. are rejected:
api.get(path`/users/${id}/files/${name}`); // name "a b" → /users/42/files/a%20b
An encoded / or \ (%2F, %5C) in a path is rejected, because many servers decode it back into a separator. So a value containing / is rejected too. If an API really expects them (e.g. file keys like a%2Fb), list it in trustedAPIs:
class FilesClient extends HttpClient {
constructor() {
super('https://files.example.com/api', {}, { trustedAPIs: ['https://files.example.com/api/objects/'] });
}
}
A URL is trusted when it has the same origin as an entry and its path is under the entry's path, segment by segment: /api/v1 covers /api/v1/users but not /api/v10.
Interceptors
AppHttpClient.addRequestInterceptor((url, options) => console.log('→', url, options));
AppHttpClient.addResponseInterceptor((response) => console.log('←', response.status));
The request interceptor fires once per call, right before fetch, with the final URL and request options (options.headers is a Headers object). The response interceptor fires right after fetch resolves, before error-status validation, so it sees failed (4xx/5xx) responses too. Each response interceptor gets its own response.clone(): reading its body doesn't consume the response the client parses.