Stone.jsDocs
Paradigm

Extensions

Resources

Your internal model and your public representation are not the same thing. A resource is the deliberate projection between them, declared as a schema, which is what lets one declaration project the response, hold it against its own promise before sending, and document it in the published contract.

#Install

terminalbash
npm i @stone-js/resources

#Declare what leaves

The principle

A projection written as code answers "what does this endpoint return?" only to someone who reads it and trusts it. Nothing checks it, nothing documents it, and a field added to the model later leaks because a mapping was not updated.

In Stone.js

A resource declares a schema instead. The schema is the projection: what it does not describe is not exposed, and the same declaration validates the response before it is sent and gives @stone-js/openapi the exact output contract.

app/resources/TaskResource.tsts
import { ApiResource, Resource } from '@stone-js/resources'
import { z } from 'zod'

@ApiResource('task')
export class TaskResource extends Resource<Task> {
  schema () {
    return z.object({
      id: z.number(),
      title: z.string(),
      done: z.boolean()
      // no ownerId, no internal flags: the schema is the contract, in both directions.
    })
  }
}

The imperative form declares exactly the same things, because neither paradigm may do something the other cannot:

app/resources/task.tsts
export const taskResource = defineResource<Task>({
  schema: z.object({ id: z.number(), title: z.string(), done: z.boolean() })
})

#Completing the model first

A projection often needs more than the model it was handed: a relation to fetch, a label to translate, a total to compute. data() is that step, and it is asynchronous and resolved from the container, so it may reach any service. Whatever it returns is what the schema then validates.

app/resources/TaskResource.tsts
constructor ({ comments }) {
  super()
  this.comments = comments
}

async data (task: Task) {
  return { ...task, commentCount: await this.comments.countFor(task.id) }
}

#When your payloads travel in an envelope

An endpoint answering a page returns something like { items, meta }, and items and meta are not fields of a model: shaping that object would publish the wrapper as if it were the thing. Name your envelope once and the payload inside it is what gets shaped, with counts and cursors left exactly as they were.

stone.config.mjsts
blueprint.set('stone.resources.envelope', { payload: 'items' })
// or several words, if your API has more than one: { payload: ['items', 'data'] }

#Typing who is asking

Deciding what a caller may see is the most common reason two callers get different shapes, so a resource can say what its event and its principal are. The type parameters travel down into every signature you write, and nothing needs a cast:

app/resources/MyAccountResource.tsts
class MyAccountResource extends Resource<Account, ResourceOutput, IncomingHttpEvent, Actor> {
  schema (context: ResourceContext<IncomingHttpEvent, Actor>) {
    return context.principal?.isSelf === true ? fullSchema : publicSchema   // typed, not unknown
  }

  async data (account: Account, context: ResourceContext<IncomingHttpEvent, Actor>) {
    return { ...account, email: context.principal?.actorId === account.id ? account.email : undefined }
  }
}

Both are unknown by default, so a resource that does not care writes nothing. data() and fragments() can be written as methods or as arrow properties; both forms are accepted deliberately, because the natural way to write an override in a class is a method, and a strict application must still be able to narrow the context.

#The contract is protected, not merely published

Data that breaks the schema does not go out. A caller cannot detect a broken contract, and a client generated from it breaks on the field that was supposed to be there, so a breach raises ResourceContractError carrying which field failed.

It fires on a genuine breach, not on a difference: a schema strips what it does not describe, so extra fields are simply not exposed. An application that would rather answer than be correct can say so, explicitly, with onViolation: 'warn'. The breach then reaches the log instead of the caller.

#Fragments a caller may ask for

A named subset is a contract of its own, with its own schema, which is what makes exposing one safe. Declare them and a caller selects one with a query parameter; the contract names them too, so nothing is discovered by guessing.

app/resources/TaskResource.tsts
fragments () {
  return { summary: z.object({ id: z.number(), title: z.string() }) }
}

// GET /tasks?view=summary

The parameter names are configuration, not convention: an API that already answers ?only= keeps its vocabulary:

app/Application.tsts
@Resources({ params: { fragment: 'only' }, onViolation: 'throw' })
export class Application {}

#Using it directly

Every projection is asynchronous, because completing a model may reach a service and pretending otherwise is how a promise ends up serialised as an empty object.

app/Tasks.tsts
const one = await taskResource.item(task)
const many = await taskResource.collection(tasks)
const page = await taskResource.response(tasks, {}, { total: 120 })   // { data, meta }

#Declaring it instead of calling it

A handler that returns its domain model and lets the route say how it is shaped keeps the projection out of the business logic. The middleware applies it to whatever the handler returned, so the handler goes back to answering the question it was asked.

app/TasksController.tsts
@Get('/', { resource: TaskResource })
list (): Task[] {
  return this.tasks.list()        // the model, whole; the route decides what leaves
}

@Returns(TaskResource)            // the same thing, with no route to hang it on
handle (): Task[] {}

Sparse fieldsets still work from the request (?fields=id,title), and a resource registered by name is referred to as a string, so a route imports nothing:

app/resources/TaskResource.tsts
@ApiResource('task')
export class TaskResource {}

// @Get('/', { resource: 'task' })

#The API

MemberTypeDescription
schema(ctx)contractWhat this resource exposes. Required: it is the projection, the validation and the documentation.
fragments(ctx)contractsNamed subsets a caller may select, each with its own schema.
data(model, ctx)asyncOptional: complete or reshape the model before it meets the schema. May reach any service.
item / collection / responseasyncProject one, many, or into a { data, meta } envelope.
when / whenIncludedconditionalDrop a field unless a condition holds, or unless the caller asked for the relation.

Stone.js

Your app exists in every runtime. Until you run it.

An open-source project by Stone Foundation
Created by Mr. Stone (Evens Pierre)