Skip to content

rbbydotdev/spac

Repository files navigation

spac

TypeScript DSL for authoring OpenAPI 3.1+ specs. Uses TypeBox for JSON Schema with full type inference — you write TypeScript, not YAML.

CI npm TypeScript 5.7+

Docs · Playground · Getting Started · API Reference

Hover tooltip showing .get() method signature and JSDoc in the plantstore example

Source mapping — TypeScript to OpenAPI YAML

Playground showing TypeScript source on the left mapped to generated OpenAPI YAML on the right


import { Api, named } from '@spec-spac/spac'
import { Type } from '@sinclair/typebox'

const Pet = named('Pet', Type.Object({ id: Type.String(), name: Type.String() }))
const api = new Api('3.1', 'Petstore', { version: '1.0.0' })

api.group('/pets', g => {
  g.get('/').response(Type.Array(Pet)).summary('List all pets')
  g.post('/').body(Pet).response(Pet).summary('Create a pet')
  g.delete('/:id').params(Type.Object({ id: Type.String() })).respond(204)
})

const spec = api.emit() // valid OpenAPI 3.1 JSON

Named schemas automatically hoist to components.schemas as $refs. Groups inherit tags and security. Macros let you compose reusable route/group patterns.

Quick start

1. Install

npm install @spec-spac/spac @sinclair/typebox

2. Define your API

Create api.ts:

import { Api, named } from '@spec-spac/spac'
import { Type } from '@sinclair/typebox'

const User = named('User', Type.Object({
  id: Type.String(),
  name: Type.String(),
  email: Type.String({ format: 'email' }),
}))

const api = new Api('3.1', 'My API', { version: '1.0.0' })

api.group('/users', g => {
  g.tag('users')
  g.get('/').response(Type.Array(User)).summary('List users')
  g.get('/:id').params(Type.Object({ id: Type.String() })).response(User)
  g.post('/').body(User).response(User).summary('Create user')
})

console.log(JSON.stringify(api.emit(), null, 2))

3. Run it

npx tsx api.ts > openapi.json

That's it — openapi.json is a valid OpenAPI 3.1 document with User hoisted to components.schemas.

Packages

Package Install What it does
@spec-spac/spac npm i @spec-spac/spac Core library — define routes, groups, schemas, emit OpenAPI 3.1 JSON/YAML with source maps
@spec-spac/from-openapi npm i -g @spec-spac/from-openapi CLI + library — reverse-generate spac TypeScript from an existing OpenAPI spec
@spec-spac/from-openapi-biome npm i @spec-spac/from-openapi-biome Biome formatter plugin for the code generator
@spec-spac/from-openapi-prettier npm i @spec-spac/from-openapi-prettier Prettier formatter plugin for the code generator

spac-from-openapi CLI

Already have an OpenAPI spec? Generate spac TypeScript from it:

# Preview what will be generated (dry run)
npx @spec-spac/from-openapi petstore.json

# Generate to a directory
npx spac-from-openapi petstore.json --out ./generated

# Strip path prefixes for cleaner grouping
npx spac-from-openapi cloudflare.json --out ./generated \
  --strip '/accounts/{account_id}' \
  --strip '/zones/{zone_id}'
Flag Description
--out <dir> Output directory (omit for dry-run)
--strip <prefix> Path prefix to strip before grouping (repeatable)
--name <name> Override API title
--spec-version <ver> Override OpenAPI version
--debug Enable source map support in generated code

Output structure:

generated/
  index.ts              — Api setup, imports all groups
  shared/schemas.ts     — Schemas used by 2+ groups
  <group>/index.ts      — Routes for that group
  <group>/schemas.ts    — Schemas local to that group

Core concepts

Routes and chaining

Every HTTP method returns a builder — all configuration is chained:

api.get('/users/:id')
  .params(Type.Object({ id: Type.String() }))
  .query(Type.Object({ fields: Type.Optional(Type.String()) }))
  .response(User)
  .error(404, ErrorSchema)
  .summary('Get a user by ID')
  .operationId('getUser')
  .tag('users')

Groups

Groups collect routes under a shared prefix with inherited metadata:

api.group('/users', g => {
  g.tag('users')
  g.security('bearer')
  g.get('/').response(Type.Array(User))
  g.post('/').body(CreateUser).response(User)
  g.get('/:id').params(Type.Object({ id: Type.String() })).response(User)
})

Named schemas

Wrap any TypeBox schema with named() to hoist it to components.schemas:

const Pet = named('Pet', Type.Object({ id: Type.String(), name: Type.String() }))
// Any route referencing Pet emits { "$ref": "#/components/schemas/Pet" }

Response helpers

import { noContent, created, errorSchema } from '@spec-spac/spac'

api.post('/items')
  .body(CreateItem)
  .respond(201, created(Item))
  .respond(204, noContent())
  .error(400, errorSchema('Bad request'))

Macros

Reusable transforms applied via .use():

import { macro } from '@spec-spac/spac'

const authenticated = macro.route(r => r.security('bearer'))
const audited = macro.group(g => { g.tag('audited'); g.security('bearer') })

api.group('/admin', g => {
  g.use(audited)
  g.get('/stats').response(Stats).use(authenticated)
})

Source maps

Map every line of emitted YAML back to the TypeScript that produced it:

const api = new Api('3.1', 'Petstore', { version: '1.0.0', debug: true })
// ... define routes ...

const result = api.emit({ sourceMap: true, generatedFile: 'spec.yaml' })
result.yaml        // YAML string
result.sourceMap   // standard Source Map V3
result.sourceTable // Map<jsonPath, SourceEntry>

API reference

Method Description
new Api(specVersion, title, config?) Create a new API spec (specVersion: '3.1')
.get/.post/.put/.delete/.patch(path) Top-level route (returns builder for chaining)
.group(prefix, callback) Group routes under a shared path prefix
.server(config) Add a server
.securityScheme(name, config) Register a security scheme
.tag(config) Register a tag
.security(name) Apply global security
.use(macro) Apply an API-level macro
.emit() Produce the OpenAPI 3.1 JSON document
.emit({ yaml: true }) Emit as YAML string
.emit({ sourceMap: true }) Emit YAML + Source Map V3 (requires debug: true)

RouteBuilder chaining

.params() .query() .headers() .body() .response() .respond() .error() .summary() .description() .tag() .operationId() .deprecated() .security() .server() .extension() .use()

Contributing

Prerequisites

  • Node.js 22+
  • pnpm 10+

Setup

git clone https://github.com/rbbydotdev/spac.git
cd spac
pnpm install

Monorepo structure

packages/
  spac/            — Core library (@spec-spac/spac)
  from-openapi/    — CLI + library for reverse-generating spac from OpenAPI
  playground/      — Interactive playground (Vite + React + CodeMirror)
  website/         — Documentation site (Next.js + Fumadocs)
  theme/           — Shared Shiki syntax themes
  spac-vscode/     — VS Code extension
  examples/        — Example projects (petstore, plantstore, serpapi, etc.)

Build & test

# Build the core library
pnpm --filter @spec-spac/spac build

# Run core tests
pnpm --filter @spec-spac/spac test

# Build + test the from-openapi package
pnpm --filter @spec-spac/from-openapi build
pnpm --filter @spec-spac/from-openapi test

Running locally

Docs site (Next.js):

pnpm --filter website dev
# → http://localhost:3000

Playground (Vite):

# Generate fixtures first (required once, or after changing examples/spac source)
pnpm --filter spac-playground generate

# Start dev server
pnpm --filter spac-playground dev

The generate step bundles type declarations, builds source maps, and creates example fixtures the playground needs. Re-run it after modifying example specs or the spac package.

Playground e2e tests

# Install browser (first time only)
pnpm exec --filter spac-playground playwright install chromium

# Run tests
pnpm --filter spac-playground test:e2e

Formatting

pnpm format        # auto-fix with Biome
pnpm format:check  # check only (used in CI)

License

MIT

About

TypeScript DSL for authoring OpenAPI 3.1+ specs, write TypeScript - not YAML

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors