npm package: @leodamours/jsonapi-client
Description: A typed HTTP client for JSON:API — powered by Axios with full type inference
Author: Leo Damours
Latest version: 1.1.0

# @leodamours/jsonapi-client

[![CI](https://github.com/LeoDamours/json-api-client/actions/workflows/ci.yml/badge.svg)](https://github.com/LeoDamours/json-api-client/actions/workflows/ci.yml)
[![npm](https://img.shields.io/npm/v/@leodamours/jsonapi-client)](https://www.npmjs.com/package/@leodamours/jsonapi-client)

A typed HTTP client for [JSON:API](https://jsonapi.org/) — full CRUD with automatic type inference, powered by Axios.

## Install

```bash
npm install @leodamours/jsonapi-client @leodamours/jsonapi-dsl axios
```

## Quick Start

```ts
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: "leo@example.com", 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

```ts
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:

```ts
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: "leo@example.com" },
});

// 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; // string
```

## Payload Types

Use `CreatePayload` and `UpdatePayload` for standalone typing:

```ts
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:

```ts
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 error
```

**Filter 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:

```ts
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]=name
```

## Response 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):

```ts
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:

```ts
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`:

```ts
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 included
```

Resolution 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:**

```ts
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 document
```

**Deserialization types** for standalone use:

```ts
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}`:

```ts
// 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](https://jsonapi.org/format/#crud-updating-relationships) without touching the resource itself:

```ts
// 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:

```ts
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:

```ts
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

```ts
// 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:

```ts
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:

```ts
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:

```ts
await api.findOne(User, "1", undefined, {
  signal: abortController.signal,
  headers: { "X-Request-Id": "abc" },
});
```

## License

MIT