npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@leodamours/jsonapi-client

v1.1.0

Published

A typed HTTP client for JSON:API — powered by Axios with full type inference

Readme

@leodamours/jsonapi-client

CI npm

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 axios

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

Payload 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 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:

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

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

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:

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