@leodamours/jsonapi-client
v1.1.0
Published
A typed HTTP client for JSON:API — powered by Axios with full type inference
Maintainers
Readme
@leodamours/jsonapi-client
A typed HTTP client for JSON:API — full CRUD with automatic type inference, powered by Axios.
Install
npm install @leodamours/jsonapi-client @leodamours/jsonapi-dsl axiosQuick Start
import { defineResource, t, hasOne, hasMany } from "@leodamours/jsonapi-dsl";
import { createClient } from "@leodamours/jsonapi-client";
const User = defineResource(
"users",
{
name: t.string(),
email: t.string(),
age: t.nullable(t.number()),
},
{
posts: hasMany("posts"),
company: hasOne("companies"),
}
);
const api = createClient({ baseURL: "https://api.example.com" });
// CREATE — attributes fully typed, relationships type-checked
const created = await api.create(User, {
attributes: { name: "Leo", email: "[email protected]", age: null },
relationships: { company: { type: "companies", id: "1" } },
});
console.log(created.data.attributes.name); // string
// FIND ONE
const user = await api.findOne(User, "1", { include: ["posts"] });
console.log(user.data.attributes.email); // string
// FIND ALL with pagination
const users = await api.findAll(User, {
page: { size: 10, number: 1 },
filter: { active: true },
sort: ["-createdAt", "name"],
});
for (const u of users.data) {
console.log(u.attributes.name);
}
// UPDATE — attributes are Partial
await api.update(User, "1", {
attributes: { name: "Updated" },
});
// DELETE
await api.remove(User, "1");Client Configuration
import { createClient } from "@leodamours/jsonapi-client";
const api = createClient({
baseURL: "https://api.example.com",
jwtToken: "your-jwt-token", // sets Authorization: Bearer ...
headers: { "X-Custom": "value" }, // extra headers
timeout: 5000, // request timeout in ms
});
// Update config at runtime (e.g. after login)
api.updateConfig({ jwtToken: "new-token" });
// Clear auth
api.updateConfig({ jwtToken: null as any });
// Access underlying Axios instance
const axiosInstance = api.getAxiosInstance();
axiosInstance.interceptors.response.use(/* ... */);Per-Verb Attribute Narrowing
When a resource is defined with create/update options, the client enforces which attributes each verb accepts:
const Author = defineResource(
"authors",
{
name: t.string(),
email: t.string(),
createdAt: t.string(),
},
{ posts: hasMany("posts") },
{
create: ["name", "email"] as const,
update: ["name"] as const,
}
);
// Only name and email accepted (createdAt is rejected at compile time)
await api.create(Author, {
attributes: { name: "Leo", email: "[email protected]" },
});
// Only name accepted
await api.update(Author, "1", {
attributes: { name: "Updated" },
});
// Response still has all attributes
const doc = await api.findOne(Author, "1");
doc.data.attributes.createdAt; // stringPayload Types
Use CreatePayload and UpdatePayload for standalone typing:
import type { CreatePayload, UpdatePayload } from "@leodamours/jsonapi-client";
type CreateUser = CreatePayload<typeof User>;
// { attributes: { name: string; email: string; age: number | null }; relationships?: ... }
type UpdateUser = UpdatePayload<typeof User>;
// { attributes?: Partial<{ name: string; email: string; age: number | null }>; relationships?: ... }Type-Safe Query Parameters
Query parameters are type-checked against the resource definition:
await api.findAll(User, {
include: ["posts", "company"], // ✓ validated against User's relationships
sort: ["-age", "name"], // ✓ validated against User's attributes
filter: { name: "Leo" }, // autocomplete for attribute names
fields: { users: ["name", "email"] }, // ✓ validated against User's attributes
page: { size: 10, number: 2 },
});
// Compile-time errors:
await api.findAll(User, { include: ["invalid"] }); // ✗ not a relationship
await api.findAll(User, { sort: ["-invalid"] }); // ✗ not an attribute
// Nested include paths — first segment validated:
await api.findOne(Post, "1", { include: ["author.company"] }); // ✓
// Relationship method names are also typed:
await api.getRelationship(Post, "1", "author"); // ✓
await api.getRelationship(Post, "1", "invalid"); // ✗ type errorFilter keys are permissive — attribute names get autocomplete, but arbitrary keys (e.g., search, active) are accepted since JSON:API filter keys are server-defined.
The untyped QueryParams type is still exported as an escape hatch for dynamic use cases.
Serialization
Query params are serialized following JSON:API conventions:
page: { size: 10 } // → page[size]=10
filter: { active: true } // → filter[active]=true
sort: ["-createdAt", "name"] // → sort=-createdAt,name
include: ["posts", "company"] // → include=posts,company
fields: { users: ["name"] } // → fields[users]=nameResponse Deserialization
Client methods return raw JSON:API documents with nested structure (data.attributes, data.relationships). Use deserialize / deserializeMany from the DSL to flatten them into plain objects (also re-exported from this package for convenience):
import { deserialize, deserializeMany } from "@leodamours/jsonapi-dsl";
const doc = await api.findOne(Post, "1", { include: ["author", "comments"] });Without registry — relationships stay as { type, id } linkage:
const { data: post } = deserialize(Post, doc);
post.id; // string
post.type; // "posts"
post.title; // string (was doc.data.attributes.title)
post.author; // { type: "users"; id: string } | null
post.comments; // { type: "comments"; id: string }[]With registry — relationships resolved one level from included:
const { data: post } = deserialize(Post, doc, {
users: User,
comments: Comment,
});
post.author.name; // string — resolved from included
post.author.email; // string
post.comments[0].body; // string — resolved from includedResolution is one level deep: a resolved author's own relationships (e.g. company) remain as { type, id } linkage. If a relationship's type isn't in the registry or the resource isn't in included, it falls back to linkage.
Collections:
const allPosts = await api.findAll(Post, {
page: { size: 10 },
include: ["author"],
});
const { data: posts, meta, links } = deserializeMany(Post, allPosts, {
users: User,
});
// posts[0].title — string
// posts[0].author — resolved User or linkage
// meta, links — preserved from documentDeserialization types for standalone use:
import type {
Deserialized,
DeserializedWith,
DeserializeResult,
DeserializeManyResult,
} from "@leodamours/jsonapi-dsl";
type FlatPost = Deserialized<typeof Post>;
// { id: string; type: "posts"; title: string; content: string; author: { type: "users"; id: string } | null; ... }
type ResolvedPost = DeserializedWith<typeof Post, { users: typeof User }>;
// { id: string; type: "posts"; title: string; author: Deserialized<typeof User> | null; ... }Related Resource Endpoints
Fetch the actual related resources (not just linkage) via /{type}/{id}/{rel}:
// To-one: returns a single document
const author = await api.findRelatedOne(Post, "1", "author", User);
author.data.attributes.name; // string — fully typed as User
// To-many: returns a collection document
const comments = await api.findRelatedMany(Post, "1", "comments", Comment, {
sort: ["-createdAt"],
page: { size: 10 },
});
for (const c of comments.data) {
c.attributes.body; // string — fully typed as Comment
}Query params are typed against the related resource definition, while the relationship name is validated against the parent.
Relationship Endpoints
Manipulate relationships directly via JSON:API relationship endpoints without touching the resource itself:
// GET — read relationship linkage
const rel = await api.getRelationship(Post, "1", "comments");
rel.data; // { type: "comments"; id: string }[]
// POST — add members to a to-many relationship
await api.addRelationship(Post, "1", "comments", [
{ type: "comments", id: "10" },
]);
// PATCH — replace a relationship entirely
await api.replaceRelationship(Post, "1", "author", {
type: "users",
id: "2",
});
// PATCH — clear a to-one relationship
await api.replaceRelationship(Post, "1", "author", null);
// DELETE — remove members from a to-many relationship
await api.removeRelationship(Post, "1", "comments", [
{ type: "comments", id: "10" },
]);All relationship methods operate on /{resourceType}/{id}/relationships/{name} and return a RelationshipResponse with the updated linkage.
Type-Safe Included Resources
When using include, the response's included array contains mixed resource types. Use isResourceOf from the DSL to narrow them:
import { isResourceOf } from "@leodamours/jsonapi-dsl";
const result = await api.findOne(Post, "1", {
include: ["author.company", "comments"],
});
const users = result.included?.filter(isResourceOf(User));
// ^? InferResource<typeof User>[]
const companies = result.included?.filter(isResourceOf(Company));
// ^? InferResource<typeof Company>[]
const comments = result.included?.filter(isResourceOf(Comment));
// ^? InferResource<typeof Comment>[]Pagination Iterator
Auto-follow links.next to iterate through all pages of a collection:
for await (const page of api.paginate(User, { page: { size: 25 } })) {
for (const user of page.data) {
console.log(user.attributes.name);
}
console.log(page.meta); // page-level meta
console.log(page.links); // page-level links
}The first page uses the same query params as findAll. Subsequent pages follow the links.next URL provided by the server.
Options
// Limit the number of pages fetched (prevents runaway iteration)
for await (const page of api.paginate(User, {}, { maxPages: 5 })) {
// stops after 5 pages even if there are more
}
// Combine with typed query params
for await (const page of api.paginate(Post, {
sort: ["-publishedAt"],
include: ["author"],
filter: { status: "published" },
page: { size: 10 },
})) {
// ...
}JSON:API Error Handling
The client automatically wraps JSON:API error responses in a typed JsonApiClientError. When a server responds with { errors: [...] }, the client throws a JsonApiClientError instead of a raw Axios error:
import { isJsonApiError } from "@leodamours/jsonapi-client";
try {
await api.findOne(User, "9999");
} catch (err) {
if (isJsonApiError(err)) {
err.status; // 404 — HTTP status code
err.errors; // JsonApiError[] — full array from response
err.firstError?.detail; // "User not found"
err.firstError?.source; // { pointer: "/data/attributes/name" }
err.hasStatus("404"); // true — check across all errors
err.hasCode("NOT_FOUND");// true/false — check error codes
err.details; // string[] — all detail messages
err.cause; // AxiosError — original error for advanced use
}
}Non-JSON:API errors (network failures, timeouts) are thrown as-is from Axios.
The JsonApiClientError class and isJsonApiError type guard are exported from the package.
Serialization Utilities
Lower-level serialization functions are exported for custom use:
import {
serializeCreatePayload,
serializeUpdatePayload,
serializeQueryParams,
serializeRelationships,
} from "@leodamours/jsonapi-client";Axios Integration
The client uses Axios under the hood. All CRUD methods accept an optional AxiosRequestConfig as the last argument:
await api.findOne(User, "1", undefined, {
signal: abortController.signal,
headers: { "X-Request-Id": "abc" },
});License
MIT
