NbJson is the Phoenix JSON API companion for the nb_ ecosystem. Where
nb_inertia optimizes Phoenix + Inertia.js DX, nb_json focuses on Phoenix
controllers that serve JSON APIs.
The package is designed as a composition layer, not a replacement for existing packages:
nb_serializerowns resource serialization.nb_tscan consume contracts for TypeScript output.nb_routescan provide typed API route helpers.nb_flopcan provide pagination/filter metadata.nb_jsonties those pieces together for API contracts, response envelopes, OpenAPI metadata, and Phoenix rendering.
- Declare request and response contracts next to controller actions.
- Render consistent success and error envelopes.
- Materialize
nb_serializertuples automatically when installed. - Generate OpenAPI from the same declarations, with
nb_serializercomponents. - Interoperate with
open_api_spexfor serving, casting, and validation. - Keep every integration optional so Phoenix APIs can adopt pieces gradually.
def deps do
[
{:nb_json, "~> 0.1.0"}
]
endWith Igniter:
mix igniter.install nb_json --with-typescriptBy default the installer also adds open_api_spex because that is the
recommended production path for serving specs and validating requests. Pass
--no-with-open-api-spex only when you want plain JSON spec generation.
defmodule MyAppWeb.UserController do
use MyAppWeb, :controller
use NbJson.Controller
alias MyApp.Accounts
alias MyAppWeb.Serializers.UserSerializer
plug OpenApiSpex.Plug.CastAndValidate, json_render_error_v2: true
plug NbJson.Plug.Validate
json_endpoint :index,
method: :get,
path: "/api/users",
tags: ["Users"],
security: [bearerAuth: []] do
params do
field :page, :integer, optional: true
field :search, :string, optional: true
end
response 200, description: "Users list" do
data :users, list_of(ref(UserSerializer))
meta :pagination, shape(page: :integer, total: :integer)
end
error 422, code: :validation_failed
end
def index(conn, _params) do
params = conn.private.nb_json_params
users = Accounts.list_users(params)
render_json(conn, :index,
users: serialize(UserSerializer, users),
meta: %{pagination: %{page: 1, total: length(users)}}
)
end
endThe response shape is:
{
"data": {
"users": []
},
"meta": {
"pagination": {
"page": 1,
"total": 0
}
}
}render_error(conn, :not_found, "User not found"){
"error": {
"code": "not_found",
"message": "User not found",
"status": 404
}
}Validation details can be built from plain maps or Ecto changesets:
NbJson.Response.validation_error(changeset)json_endpoint contracts are validated while the controller module compiles.
These mistakes fail before the app boots:
- duplicate endpoint names
- duplicate request params
- duplicate response status codes
- duplicate
dataormetaresponse keys - missing success responses
- invalid HTTP status codes
- invalid param locations
- path placeholders without matching
location: :pathfields location: :pathfields that do not appear in the path- duplicate fields inside
shape(...)
For controller modules, enable action verification when you want every
json_endpoint :name to require a matching def name(conn, params):
use NbJson.Controller, validate_actions: trueThat option is off by default so contract-only modules can still be used for tests, generated specs, or shared API descriptions.
Declared params can validate regular Phoenix params maps. Values are returned
with atom keys and common scalar types are coerced from strings:
case validate_json_params(:index, %{"page" => "2", "search" => "ada"}) do
{:ok, params} ->
# %{page: 2, search: "ada"}
render_json(conn, :index, users: list_users(params))
{:error, errors} ->
# %{page: ["must be an integer"]}
render_validation_error(conn, errors)
endOr let NbJson.Plug.Validate validate every request before it reaches the
action:
plug NbJson.Plug.Validate
def index(conn, _params) do
params = conn.private.nb_json_params
render_json(conn, :index, users: Accounts.list_users(params))
endThe plug infers the controller/action in Phoenix. For router pipelines or other
Plug apps, pass controller: and endpoint: explicitly.
nb_json treats auth as API contract metadata plus adapter-based enforcement.
Your Phoenix app still owns users, tokens, sessions, tenants, roles, and policy
rules.
Declare authentication and authorization next to the endpoint contract:
json_endpoint :show,
method: :get,
path: "/api/accounts/:account_id/users/:id",
auth: [scheme: :bearer, scopes: ["users:read"]] do
params do
field :account_id, :uuid, location: :path
field :id, :uuid, location: :path
end
authorize resource: :user, action: :read, id: :id, tenant: :account_id
response 200 do
data :user, ref(UserSerializer)
end
endProvide app adapters:
defmodule MyAppWeb.ApiAuth do
@behaviour NbJson.Auth
def authenticate(conn, auth, _opts) do
# Verify bearer/API key/session credentials with your app.
# Return {:ok, subject, claims} or {:error, :missing | :invalid | :expired}.
end
end
defmodule MyAppWeb.ApiPolicy do
@behaviour NbJson.Authorization
def authorize(current_user, requirement, conn, _opts) do
# Evaluate tenant/object/action policy with your app rules.
:ok
end
endConfigure them once and plug them before request validation:
config :nb_json,
auth_adapter: MyAppWeb.ApiAuth,
authorization_adapter: MyAppWeb.ApiPolicy
plug NbJson.Plug.Secure
plug NbJson.Plug.ValidateNbJson.Plug.Secure runs authentication and authorization in order. Successful
authentication stores :nb_json_subject and :nb_json_claims in both assigns
and private data. Failures return standard 401 or 403 error envelopes, with
WWW-Authenticate for bearer failures.
The same auth: declaration generates OpenAPI security requirements and
security schemes. Supported defaults include :bearer, :basic, :api_key,
and :cookie. Custom schemes must pass security_scheme: plus open_api:, or
open_api: nil when you provide the component through OpenAPI generation opts.
Generate an OpenAPI document from controller declarations. Serializer refs that
expose nb_serializer metadata are expanded into reusable component schemas:
mix nb_json.openapi MyAppWeb.UserController --output priv/openapi.jsonOr from Elixir:
NbJson.OpenApi.to_map([MyAppWeb.UserController],
title: "My API",
version: "1.0.0"
)For production Phoenix apps, install open_api_spex and expose the same
contract as an OpenApiSpex.OpenApi spec:
defmodule MyAppWeb.ApiSpec do
use NbJson.OpenApiSpex,
controllers: [MyAppWeb.UserController],
title: "My API",
version: "1.0.0",
servers: ["https://api.example.com"],
security_schemes: [
bearerAuth: :bearer
]
endThen put the spec in the API pipeline and run request validation in controllers
that declare json_endpoint contracts:
pipeline :api do
plug OpenApiSpex.Plug.PutApiSpec, module: MyAppWeb.ApiSpec
end
scope "/api" do
pipe_through :api
get "/openapi", OpenApiSpex.Plug.RenderSpec, []
end
defmodule MyAppWeb.UserController do
use MyAppWeb, :controller
use NbJson.Controller
plug OpenApiSpex.Plug.CastAndValidate, json_render_error_v2: true
plug NbJson.Plug.Validate
endOpenApiSpex.Plug.CastAndValidate should run in the controller, where Phoenix
has already assigned controller/action metadata for operation inference.
NbJson.OpenApi.to_open_api_spex/2 is also available for tests and custom
integration code.
Endpoint metadata maps directly to OpenAPI operation fields:
json_endpoint :create,
method: :post,
path: "/api/users",
operation_id: "users.create",
tags: ["Users"],
security: [%{bearerAuth: ["users:write"]}],
request_body_description: "Create a user" do
params do
field :name, :string
end
response 201 do
data :user, ref(UserSerializer)
end
endIn controller tests, validate actual responses against the generated spec:
import NbJson.TestAssertions
conn =
conn
|> OpenApiSpex.Plug.PutApiSpec.call(MyAppWeb.ApiSpec)
|> get(~p"/api/users")
assert_json_response(conn, MyAppWeb.UserController, :index)Generate a dependency-free fetch client and exported request/response types from the same contracts:
mix nb_json.gen.client MyAppWeb.UserController --output assets/js/api.tsExample generated usage:
import { usersIndex } from '@/api';
const response = await usersIndex({ page: 2, search: 'ada' });
response.data.users;Generated clients accept a bearer token, async token provider, or explicit auth header map through request options:
await usersIndex({ page: 1 }, { auth: () => auth.getAccessToken() });
await usersIndex({ page: 1 }, { auth: { "x-api-key": apiKey } });By default, serializer refs import the matching generated serializer types, which
fits apps that also use nb_ts. For standalone API clients, emit fallback
aliases instead:
mix nb_json.gen.client MyAppWeb.UserController \
--output assets/js/api.ts \
--no-serializer-importsWhen you use nb_ts, the same nb_json contracts can be included in the wider
generated type bundle:
mix nb_ts.gen --output-dir assets/js/typesReact apps should use TanStack React Query for caching, background refetching,
mutations, optimistic updates, and server-state lifecycles. nb_json can emit
query keys, query option factories, and hooks next to the raw fetch helpers:
npm install @tanstack/react-query
mix nb_json.gen.client MyAppWeb.UserController \
--output assets/js/api.ts \
--react-queryGenerated GET endpoints include stable query keys, reusable query options, and
hooks:
import { usersIndexQueryOptions, useUsersIndex } from '@/api';
const users = useUsersIndex({ page: 1 });
// Also works with loaders, prefetching, and SSR helpers:
queryClient.prefetchQuery(usersIndexQueryOptions({ page: 1 }));Generated non-GET endpoints become mutations:
import { useUsersCreate, usersIndexQueryRootKey } from '@/api';
const createUser = useUsersCreate({
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: usersIndexQueryRootKey() });
}
});
createUser.mutate({ name: 'Ada', active: true });The raw fetch functions are still generated in the same file, so teams can use React Query in app code while keeping tests, scripts, and non-React clients on plain promises.
APIs that prefer JSON:API conventions can opt in per response. The profile keeps the same controller DSL, OpenAPI generation, TypeScript client output, and runtime rendering path:
json_endpoint :show, method: :get, path: "/api/articles/:id" do
params do
field :id, :uuid, location: :path
end
response 200,
profile: :json_api,
type: "articles",
relationships: [author: [type: "people"], comments: [type: "comments"]] do
data :article, ref(ArticleSerializer)
end
end
def show(conn, %{"id" => id}) do
article = Blog.get_article!(id)
render_json(conn, :show,
article: %{
id: article.id,
title: article.title,
author: %{id: article.author_id},
comments: Enum.map(article.comments, &%{id: &1.id})
}
)
endThe rendered document follows JSON:API resource object conventions:
{
"data": {
"type": "articles",
"id": "1",
"attributes": {
"title": "Typed APIs"
},
"relationships": {
"author": {
"data": { "type": "people", "id": "2" }
},
"comments": {
"data": [{ "type": "comments", "id": "3" }]
}
}
}
}profile: :json_api requires exactly one data field in the response DSL, so
bad contracts fail at compile time. At runtime :meta, :links, and
:included assigns are lifted to the JSON:API top level. Direct helper usage is
also available:
NbJson.Response.success([user: %{id: 1, name: "Ada"}],
profile: :json_api,
type: "users"
)The default suite covers the library and an in-process Phoenix/OpenApiSpex router flow. Heavier release smoke checks are tagged so they can be run before publishing or upgrading a production app:
mix test
mix test --include production_smoke test/smoke/production_smoke_test.exsThe production smoke creates a fresh Phoenix API project, installs nb_json
from the local package path, compiles it with warnings as errors, and compiles
the generated TypeScript client with strict TypeScript.
List endpoints can declare Flop-compatible pagination, sorting, and filters without repeating the same params everywhere:
json_endpoint :index, method: :get, path: "/api/users" do
params do
flop_params pagination: :all
end
response 200 do
data :users, list_of(ref(UserSerializer))
flop_meta()
end
endflop_params/1 emits query params for page, offset, and cursor pagination,
sorting, and linear filter lists. The validated output is plain atom-keyed data
that can be passed into your Flop query layer.
NbJson.Controller imports the same Elixir-first type style used across the
nb_ packages:
field :id, :uuid
field :status, enum([:draft, :published])
data :users, list_of(ref(UserSerializer))
meta :pagination, shape(page: :integer, total: :integer)
data :subject, union([ref(UserSerializer), ref(TeamSerializer)])
data :deleted_at, nullable(:datetime)