/** * A resource is a declaration the package publishes: an entity of some KIND, * named the way the platform names it (`service_specification`, * `link_specification`, `scope_type`, …), with the body that kind's own API * takes, written in camelCase. * * The core knows no kinds. `resource(kind, body)` accepts any kind an owning * service has registered; `service()`, `link()`, `scope()` and `scopeType()` * are typed shortcuts built on it because those kinds are everywhere. * Declarations are plain values — share them, compose them, test them. */ import { snakeCase } from "./keys"; export interface Ref { /** `/` — resolved to an id at install, at the target NRN. */ ref: string; } export interface Resource< K extends string = string, B extends object = object > { readonly kind: K; /** The API body, camelCase. Exactly the fields the kind's endpoint accepts. */ readonly body: B; /** Identity within the kind at one NRN. */ readonly slug: string; /** A reference other resources embed: `{ ref: "/" }`. */ readonly ref: Ref; /** The resource this one belongs to (a link to its service specification). */ readonly parent?: Resource; /** Nested resources the API creates under this one. */ readonly children: Resource[]; } export interface ResourceOptions { parent?: Resource; /** Override the slug when the body carries none (`name` is slugified otherwise). */ slug?: string; } const slugify = (s: string): string => s .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, ""); export function slugOf(body: object, explicit?: string): string { if (explicit) return explicit; const { slug, name } = body as { slug?: unknown; name?: unknown }; const fromBody = slug ?? name; if (typeof fromBody !== "string" || !fromBody.trim()) { throw new Error("a resource needs a slug or a name to be identified"); } return typeof slug === "string" ? slug : slugify(fromBody); } /** The generic constructor. `kind` is the API's entity name in snake_case. */ export function resource( kind: K, body: B, opts: ResourceOptions = {} ): Resource { if (!/^[a-z][a-z0-9_]*$/.test(kind)) { throw new Error( `resource kind "${kind}" must be the API's snake_case entity name, e.g. "service_specification"` ); } const slug = slugOf(body, opts.slug); const res: Resource = { kind, body, slug, ref: { ref: `${kind}/${slug}` }, parent: opts.parent, children: [], }; if (opts.parent) opts.parent.children.push(res); return res; } /** * `kinds.approvalPolicy(body)` is `resource("approval_policy", body)`: the * camelCase name of any kind, converted. Typed when editor types exist * (`np package types`), plain otherwise. */ export const kinds: Record< string, (body: B, opts?: ResourceOptions) => Resource > = new Proxy( {}, { get: (_target, prop: string | symbol) => { if (typeof prop !== "string") return undefined; const kind = snakeCase(prop); return (body: B, opts?: ResourceOptions) => resource(kind, body, opts); }, } ); export const isResource = (v: unknown): v is Resource => !!v && typeof v === "object" && typeof (v as Resource).kind === "string" && typeof (v as Resource).slug === "string" && Array.isArray((v as Resource).children);