diff --git a/README.md b/README.md index daa94bf..0cbec7e 100644 --- a/README.md +++ b/README.md @@ -193,13 +193,22 @@ See [docs/concepts.md](./docs/concepts.md) and [docs/fixtures-and-schemas.md](./ ## Admin/CMS Schema Metadata -Schemas can also drive local admin, CMS, and form-building screens. Configure `schemaOutFile` when an app needs committed field metadata such as field types, labels, defaults, enum values, relation hints, descriptions, and app-specific UI hints. +Schemas can also drive local admin, CMS, custom data viewers, and form-building screens. Use `GET /__jsondb/manifest.json` at runtime when a UI runs beside `jsondb serve`, or configure `viewerManifestOutFile` when app code needs a committed JSON artifact with the same viewer metadata. Browser requests can open `GET /__jsondb/manifest.html`; AI clients can use `GET /__jsondb/manifest.md`; `GET /__jsondb/manifest` lets the `Accept` header choose among registered response formats. + +Use `schemaOutFile` when an app only needs the smaller model metadata file without server route links, diagnostics, or viewer capabilities. ```js import { defineConfig, mergeManifest } from 'jsondb/config'; export default defineConfig({ schemaOutFile: './src/generated/jsondb.schema.json', + viewerManifestOutFile: './src/generated/jsondb.viewer.json', + + server: { + viewerLinks: [ + { label: 'App Data Viewer', href: 'http://127.0.0.1:5173/jsondb' }, + ], + }, schemaManifest: { customizeResource({ file, defaultManifest }) { @@ -238,7 +247,7 @@ export default defineConfig({ }); ``` -The generated manifest is metadata output; schema defaults and validation still come from the schema contract. +The generated manifest is metadata output; schema defaults and validation still come from the schema contract. Actual records stay on REST or GraphQL routes, so a custom viewer fetches `manifest.json` for fields and route links, then calls the listed resource routes for rows. `server.viewerLinks` exposes custom viewer URLs in root discovery and the shared manifest. See [docs/generated-files.md](./docs/generated-files.md) and [examples/schema-manifest](./examples/schema-manifest). @@ -296,7 +305,7 @@ curl 'http://127.0.0.1:7331/users?select=id,name&offset=0&limit=20' The viewer at `/__jsondb` lets you inspect resources, import CSV files into the configured fixture folder, view generated schema metadata, read GraphQL SDL/operation references, and try REST requests without writing client code first. -The built-in viewer is intentionally small; apps can use the REST API, GraphQL endpoint, generated schema metadata, or integrations to build their own viewer UI. +The built-in viewer and custom viewer UIs use the same JSON manifest at `/__jsondb/manifest.json`. `/__jsondb/manifest.html` opens a formatted JSON viewer, `/__jsondb/manifest.md` returns an AI-friendly Markdown wrapper, and `/__jsondb/manifest` chooses from registered media types in `Accept`. Apps can use `api.formats` from the manifest to discover supported extensions and build their own viewer UI against REST or GraphQL records. See [docs/server-and-viewer.md](./docs/server-and-viewer.md). @@ -309,6 +318,7 @@ See [docs/server-and-viewer.md](./docs/server-and-viewer.md). | `.jsondb/types/index.ts` | Normally no | Default generated TypeScript output. | | `types.commitOutFile` output | Yes, when configured | Use for stable imports before sync runs. | | `schemaOutFile` output | Yes, when configured | Use for model-driven admin/CMS metadata. | +| `viewerManifestOutFile` output | Yes, when configured | Use for custom data viewers that need metadata plus route links. | | `examples/*/src/generated/jsondb.types.ts` | Yes, in selected examples | Intentionally committed example type output. | | `examples/*/src/generated/jsondb.schema.json` | Yes, in selected examples | Intentionally committed example manifest. | diff --git a/SPEC.md b/SPEC.md index 032386b..d985508 100644 --- a/SPEC.md +++ b/SPEC.md @@ -656,6 +656,7 @@ export default { }, server: { + apiBase: '/__jsondb', host: '127.0.0.1', port: 7331, maxBodyBytes: 1048576, @@ -781,14 +782,24 @@ REST batches are non-transactional by design. Items execute in order, and earlie Schema-backed writes should validate declared field types before mutating runtime state. Required fields, primitive types, enum values, arrays, nullable fields, datetime strings, flexible objects with intentional additional properties, nested objects, and field constraints (`unique`, `min`, `max`, `minLength`, `maxLength`, `pattern`) should be checked for package API writes, REST writes, GraphQL mutations, `jsondb sync`, and `jsondb schema validate`. -The root route should work as a discovery endpoint. API-style requests to `GET /` should return JSON with resource names plus links for the data viewer, schema endpoint, GraphQL endpoint, and resource routes. Browser-style requests that prefer `text/html` should return a small HTML index with those same links. +The root route should work as a discovery endpoint. API-style requests to `GET /` should return JSON with resource names plus links for the data viewer, schema endpoint, GraphQL endpoint, resource routes, and registered response formats. Browser-style requests that prefer `text/html` should return a small HTML index with those same links. The local server should also expose a built-in dependency-free viewer: ```txt GET /__jsondb +GET /__jsondb/manifest +GET /__jsondb/manifest.json +GET /__jsondb/manifest.html +GET /__jsondb/manifest.md ``` +`server.apiBase` should default to `/__jsondb` and should configure the +standalone viewer, viewer manifest, schema, batch, import, events, log, and fork route base +without changing root REST resource routes or the standalone GraphQL path. + +The viewer manifest should be the shared JSON contract used by the built-in viewer and custom data viewers. `/manifest.json` should return JSON by default. `/manifest.html` should render a formatted JSON viewer with dark mode as the default, dark/light/system controls, copy, and pretty/raw formatting controls. `/manifest.md` should render Markdown with the manifest JSON in a fenced code block for AI clients. `/manifest` should choose among registered media types from `Accept`, and explicit `/manifest.` routes should use the matching registered response format. The manifest should include API links, capabilities, diagnostics, configured viewer links, response format metadata, collections, documents, field metadata, UI hints, and relation hints. It must not include seed records, source paths, source hashes, runtime state paths, or GraphQL SDL. Custom viewers should use the manifest for UI metadata and fetch actual records from REST or GraphQL. + The viewer should support: ```txt @@ -831,7 +842,7 @@ POST /__jsondb/import The upload should copy the CSV into the configured fixture folder, run sync, reload the in-memory resources, update the URL query parameter to the imported resource, and reload the dashboard view. -While serving, jsondb should watch the configured fixture folder for fixture and schema changes, ignoring `.jsondb/`. On change, reload resources and notify the single-file viewer through `/__jsondb/events` so the dashboard refreshes automatically. If one source file fails to parse or load, report a file-specific diagnostic in the viewer and keep the remaining valid resources available. If the runtime cannot create a file watcher because of environment resource limits such as `EMFILE` or `ENOSPC`, keep the HTTP server running, publish a warning diagnostic, and serve without live reload. +While serving, jsondb should watch the configured fixture folder for fixture and schema changes, ignoring `.jsondb/`. On change, reload resources and notify the single-file viewer through the configured events route, defaulting to `/__jsondb/events`, so the dashboard refreshes automatically. If one source file fails to parse or load, report a file-specific diagnostic in the viewer and keep the remaining valid resources available. If the runtime cannot create a file watcher because of environment resource limits such as `EMFILE` or `ENOSPC`, keep the HTTP server running, publish a warning diagnostic, and serve without live reload. Vite development should be supported through a dependency-light plugin export: @@ -843,7 +854,7 @@ export default { }; ``` -The plugin should return a plain Vite-compatible plugin object with `apply: 'serve'`, mount jsondb into the existing Vite dev middleware stack, and avoid bundling Node-only fixture runtime code into production builds. By default, Vite dev routes should be scoped under `/__jsondb` and should not answer root app routes: +The plugin should return a plain Vite-compatible plugin object with `apply: 'serve'`, mount jsondb into the existing Vite dev middleware stack, and avoid bundling Node-only fixture runtime code into production builds. By default, Vite dev routes should be scoped under `/__jsondb` and should not answer root app routes. A plugin-level `apiBase` should win over `server.apiBase`: ```txt GET /__jsondb @@ -1093,6 +1104,23 @@ When `schemaOutFile` is set, `jsondb sync` writes the manifest. The CLI can also jsondb schema manifest --out ./src/generated/jsondb.schema.json ``` +Custom viewer UIs can use the live `GET /__jsondb/manifest.json` route or a committed viewer manifest. Browser users can open `GET /__jsondb/manifest.html`, AI clients can open `GET /__jsondb/manifest.md`, and `GET /__jsondb/manifest` negotiates from registered `Accept` media types: + +```js +export default { + viewerManifestOutFile: './src/generated/jsondb.viewer.json', + server: { + viewerLinks: [ + { label: 'App Data Viewer', href: 'http://127.0.0.1:5173/jsondb' }, + ], + }, +}; +``` + +```bash +jsondb viewer manifest --out ./src/generated/jsondb.viewer.json +``` + The manifest should have this top-level shape: ```json diff --git a/docs/architecture.md b/docs/architecture.md index 6794e39..a0e18e0 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -28,7 +28,7 @@ db/*.json, *.jsonc, *.csv, *.schema.json(c), *.schema.mjs | HTTP client | `src/client.js` | REST, GraphQL, direct batching, automatic batching, fork support. | | REST server | `src/server.js`, `src/rest/` | Dependency-free local routes and response shaping. | | GraphQL | `src/graphql/` | Dependency-free subset parser, executor, and HTTP handler. | -| Viewer | `src/web/` | Built-in UI served at `/__jsondb`. | +| Viewer | `src/web/` | Built-in UI served at `server.apiBase`, defaulting to `/__jsondb`. | | Vite integration | `src/vite.js`, `src/integrations/` | Optional dev server plugin and virtual client. | | Hono/SQLite | `src/hono.js`, `src/sqlite.js`, `src/generate/hono.js` | Optional runtime integration and generated starter output. | diff --git a/docs/configuration.md b/docs/configuration.md index 9a6f45c..091eed5 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -31,12 +31,14 @@ See [jsondb.config.example.mjs](../jsondb.config.example.mjs) for a commented co | Index intent metadata | Off | `resources..indexes` | | Importable generated types | `.jsondb/types/index.ts` | `types.commitOutFile` | | Importable schema manifest | Off | `schemaOutFile` | +| Importable viewer manifest | Off | `viewerManifestOutFile` | +| REST response formats | `.json`, `.html`, `.md` | `rest.formats` | | Unknown fields | Warn | `schema.unknownFields` | | Schema-only mock records | Off | `seed.generateFromSchema` | | Local latency | `30-100ms` | `mock.delay` | | Random local failures | Off | `mock.errors` | | Legacy database shapes | Off | `forks` | -| Host, port, body limit | `127.0.0.1:7331`, 1 MB bodies | `server` | +| Host, port, dev-tool route base, body limit | `127.0.0.1:7331`, `/__jsondb`, 1 MB bodies | `server` | ## Full Example @@ -66,6 +68,7 @@ export default defineConfig({ }, schemaOutFile: './src/generated/jsondb.schema.json', + viewerManifestOutFile: './src/generated/jsondb.viewer.json', schema: { unknownFields: 'warn', @@ -77,9 +80,32 @@ export default defineConfig({ }, server: { + apiBase: '/__jsondb', host: '127.0.0.1', port: 7331, maxBodyBytes: 1048576, + viewerLinks: [ + { label: 'App Data Viewer', href: 'http://127.0.0.1:5173/jsondb' }, + ], + }, + + rest: { + formats: { + default: 'json', + md({ resourceName, data }) { + return { + body: `# ${resourceName}\n\n\`\`\`json\n${JSON.stringify(data, null, 2)}\n\`\`\`\n`, + contentType: 'text/markdown; charset=utf-8', + }; + }, + // yaml: { + // mediaTypes: ['application/yaml', 'text/yaml'], + // contentType: 'application/yaml; charset=utf-8', + // render({ data }) { + // return stringifyYaml(data); + // }, + // }, + }, }, mock: { @@ -191,13 +217,14 @@ Random errors stay off by default. Turn them on when testing retries and error U ## Server Options -Use `server` for a different host, port, or JSON body limit: +Use `server` for a different host, port, dev-tool route base, or JSON body limit: ```js import { defineConfig } from 'jsondb/config'; export default defineConfig({ server: { + apiBase: '/__jsondb', host: '127.0.0.1', port: 7331, maxBodyBytes: 1048576, @@ -205,6 +232,11 @@ export default defineConfig({ }); ``` +`server.apiBase` scopes the standalone viewer and internal development routes: +viewer, schema, batch, import, live events, runtime log, and fork routes. REST +resources such as `/users` and the standalone GraphQL path stay unchanged unless +you configure those surfaces separately. + ## Database Forks Use forks when part of an app needs an older fixture shape while other pages move to a new shape. diff --git a/docs/generated-files.md b/docs/generated-files.md index 64ea052..a3d8991 100644 --- a/docs/generated-files.md +++ b/docs/generated-files.md @@ -95,11 +95,32 @@ The manifest includes normalized resource and field metadata such as `type`, `re The manifests at [examples/schema-manifest/src/generated/jsondb.schema.json](../examples/schema-manifest/src/generated/jsondb.schema.json) and [examples/schema-ui/src/generated/jsondb.schema.json](../examples/schema-ui/src/generated/jsondb.schema.json) are intentionally committed. +## Viewer Manifest Output + +Use `viewerManifestOutFile` when a custom data viewer needs the same JSON metadata used by the built-in viewer: + +```js +import { defineConfig } from 'jsondb/config'; + +export default defineConfig({ + viewerManifestOutFile: './src/generated/jsondb.viewer.json', +}); +``` + +`jsondb sync` writes the viewer manifest when `viewerManifestOutFile` is set. You can also generate it directly: + +```bash +npm run db -- viewer manifest --out ./src/generated/jsondb.viewer.json +``` + +The viewer manifest includes field metadata, UI hints, relation hints, diagnostics, capabilities, configured viewer links, and API links such as `/__jsondb/manifest`, `/__jsondb/manifest.json`, `/__jsondb/manifest.html`, `/__jsondb/manifest.md`, `/__jsondb/batch`, `/graphql`, and each REST resource route. It does not include seed records, source paths, source hashes, runtime state paths, or GraphQL SDL. Fetch actual records from REST or GraphQL. + ## Cleanup Rules - Do not commit `.jsondb/` unless a task explicitly asks for generated runtime state. - Do commit configured `types.commitOutFile` output when an app needs stable imports in a fresh checkout. - Do commit configured `schemaOutFile` output when an app needs stable schema metadata at runtime. +- Do commit configured `viewerManifestOutFile` output when a custom viewer needs stable metadata and route links at runtime. - Smoke commands against examples may create `examples/*/.jsondb/`; remove that generated runtime output before finalizing. ## Related Examples diff --git a/docs/integrations.md b/docs/integrations.md index 1ea278d..ba6e619 100644 --- a/docs/integrations.md +++ b/docs/integrations.md @@ -43,6 +43,7 @@ const legacyUsers = await legacyDb.rest.get('/users'); ``` Plugin options include `cwd`, `dbDir`, `stateDir`, `forks`, `apiBase`, `restBasePath`, `graphqlPath`, `rootRoutes`, `clientVirtualModule`, and `clientImport`. +The plugin uses `apiBase` first, then `server.apiBase`, then `/__jsondb` for scoped dev routes. Set `rootRoutes: true` only when you intentionally want Vite dev to also answer unscoped routes like `/users`. Standalone `jsondb serve` keeps those root REST routes by default. diff --git a/docs/package-api.md b/docs/package-api.md index a14f1e2..ec89326 100644 --- a/docs/package-api.md +++ b/docs/package-api.md @@ -17,6 +17,7 @@ npm run db -- schema infer users npm run db -- schema infer users --out db/users.schema.jsonc npm run db -- schema manifest --out ./src/generated/jsondb.schema.json npm run db -- schema validate +npm run db -- viewer manifest --out ./src/generated/jsondb.viewer.json npm run db -- doctor npm run db -- doctor --json npm run db -- check --strict @@ -32,6 +33,7 @@ Inside npm scripts, `jsondb` resolves to the local dependency binary. Equivalent jsondb sync jsondb types jsondb schema validate +jsondb viewer manifest --out ./src/generated/jsondb.viewer.json jsondb doctor jsondb check --strict jsondb serve diff --git a/docs/server-and-viewer.md b/docs/server-and-viewer.md index 928449a..505c07e 100644 --- a/docs/server-and-viewer.md +++ b/docs/server-and-viewer.md @@ -23,6 +23,10 @@ Open the built-in viewer after starting the server: http://127.0.0.1:7331/__jsondb ``` +The default viewer and dev-tool route base is `/__jsondb`. Change it with +`server.apiBase` when an app needs a different reserved path; schema, batch, +import, events, log, and fork routes move with that base. + Opening `http://127.0.0.1:7331/` in a browser shows a small index with links to the data viewer, schema, GraphQL endpoint, and resource routes. API-style requests to `/` keep returning JSON discovery data by default. The viewer includes: @@ -35,6 +39,60 @@ The viewer includes: - schema and field inspection - source diagnostics when one fixture file is broken +## Custom Viewer Manifest + +The built-in viewer reads the same JSON manifest that custom viewer UIs can use: + +```txt +GET /__jsondb/manifest +GET /__jsondb/manifest.json +GET /__jsondb/manifest.html +GET /__jsondb/manifest.md +``` + +`/manifest.json` returns JSON. `/manifest.html` returns the built-in formatted JSON viewer with dark mode by default, dark/light/system theme controls, copy, and pretty/raw formatting controls. `/manifest.md` returns Markdown with the manifest JSON in a fenced code block for AI clients. `/manifest` chooses from registered response formats using the request `Accept` header. If `server.apiBase` changes, the routes move with it, for example `GET /_jsondb/manifest`. + +The manifest includes: + +- API links for the viewer, manifest, manifest JSON/HTML/Markdown routes, schema, events, batch, import, GraphQL, and each REST resource +- built-in and configured custom viewer links +- resource and field metadata, including generated UI hints and relation hints +- viewer capabilities such as writes, batching, CSV import, GraphQL, and live events +- diagnostics suitable for display in a custom UI + +The manifest does not include seed records, source paths, source hashes, runtime state paths, or GraphQL SDL. Custom viewers should fetch `manifest.json` for UI metadata and route links, then fetch actual records from REST or GraphQL. `api.formats` lists the registered response formats, media types, and manifest paths for custom viewers and tools. + +Add custom viewer links when a project ships its own data UI: + +Override the built-in Markdown renderer when a project needs a different shape: + +```js +import { defineConfig } from 'jsondb/config'; +import { stringify as stringifyYaml } from 'yaml'; + +export default defineConfig({ + server: { + viewerLinks: [ + { label: 'App Data Viewer', href: 'http://127.0.0.1:5173/jsondb' }, + ], + }, +}); +``` + +You can also write the same shape to a committed artifact: + +```js +import { defineConfig } from 'jsondb/config'; + +export default defineConfig({ + viewerManifestOutFile: './src/generated/jsondb.viewer.json', +}); +``` + +```bash +jsondb viewer manifest --out ./src/generated/jsondb.viewer.json +``` + ## REST Routes Collections: @@ -86,11 +144,15 @@ Resource `GET` routes return JSON by default. The explicit `.json` extension use ```txt GET /users GET /users.json +GET /users.html +GET /users.md GET /users/u_1 GET /users/u_1.json +GET /users/u_1.html +GET /users/u_1.md ``` -Configure `rest.formats` to add formats such as `.md` or `.html`. Format renderers receive data after normal REST shaping, so `select`, `expand`, `offset`, and `limit` apply before rendering. +`.json`, `.html`, and `.md` are built in. Config entries with the same extension override the built-in resource renderer, and object entries can also override manifest rendering. Extensionless resource and manifest routes negotiate registered media types from `Accept`; unsupported `Accept` values fall back to the configured default format. Format renderers receive data after normal REST shaping, so `select`, `expand`, `offset`, and `limit` apply before rendering. ```js import { defineConfig } from 'jsondb/config'; @@ -105,12 +167,22 @@ export default defineConfig({ contentType: 'text/markdown; charset=utf-8', }; }, + yaml: { + mediaTypes: ['application/yaml', 'text/yaml'], + contentType: 'application/yaml; charset=utf-8', + render({ data }) { + return stringifyYaml(data); + }, + renderManifest({ data }) { + return stringifyYaml(data); + }, + }, }, }, }); ``` -jsondb does not execute `.jsx` routes directly; JSX is a source/runtime choice for your renderer, while `.html` is the response format. +Function shorthand is resource-only for compatibility. Use object syntax when a format needs media-type negotiation or manifest support, such as `GET /__jsondb/manifest.yaml`. jsondb does not execute `.jsx` routes directly; JSX is a source/runtime choice for your renderer, while `.html` is the response format. ## Relationship Expansion @@ -147,6 +219,9 @@ REST batching is supported through: POST /__jsondb/batch ``` +If `server.apiBase` is changed, the batch endpoint follows that base, for +example `POST /_jsondb/batch`. + ```json [ { @@ -212,4 +287,6 @@ POST /__jsondb/forks/legacy-demo/graphql GET /__jsondb/forks/legacy-demo/schema ``` +These routes also follow `server.apiBase`. + See [Configuration](./configuration.md) for fork setup and [Package API](./package-api.md) for client usage. diff --git a/examples/basic/jsondb.config.mjs b/examples/basic/jsondb.config.mjs index fc9b44c..51cafb5 100644 --- a/examples/basic/jsondb.config.mjs +++ b/examples/basic/jsondb.config.mjs @@ -13,4 +13,48 @@ export default defineConfig({ schema: { unknownFields: 'warn', }, + rest: { + formats: { + yaml: { + mediaTypes: ['application/yaml', 'text/yaml'], + contentType: 'application/yaml; charset=utf-8', + render({ data }) { + return `${toYaml(data)}\n`; + }, + }, + }, + }, }); + +function toYaml(value, indent = 0) { + const pad = ' '.repeat(indent); + if (Array.isArray(value)) { + return value.map((item) => { + if (item && typeof item === 'object') { + return `${pad}-\n${toYaml(item, indent + 2)}`; + } + return `${pad}- ${formatYamlScalar(item)}`; + }).join('\n'); + } + + if (value && typeof value === 'object') { + return Object.entries(value).map(([key, item]) => { + if (item && typeof item === 'object') { + return `${pad}${key}:\n${toYaml(item, indent + 2)}`; + } + return `${pad}${key}: ${formatYamlScalar(item)}`; + }).join('\n'); + } + + return `${pad}${formatYamlScalar(value)}`; +} + +function formatYamlScalar(value) { + if (typeof value === 'string') { + return JSON.stringify(value); + } + if (value === null) { + return 'null'; + } + return String(value); +} diff --git a/jsondb.config.example.mjs b/jsondb.config.example.mjs index a2aa433..2747afd 100644 --- a/jsondb.config.example.mjs +++ b/jsondb.config.example.mjs @@ -12,6 +12,10 @@ export default defineConfig({ // Generated during `jsondb sync` when set. schemaOutFile: null, + // Optional committed JSON viewer manifest for custom data viewers. + // Includes UI metadata, diagnostics, capabilities, and route links. + viewerManifestOutFile: null, + // Optional visitor hook for changing or omitting generated manifest fields. schemaManifest: { customizeField({ defaultManifest }) { @@ -69,9 +73,32 @@ export default defineConfig({ // Local server settings. server: { + // Dev-tool route base for the viewer, schema, batch, import, events, log, + // and fork routes. Defaults to '/__jsondb'. + apiBase: '/__jsondb', host: '127.0.0.1', port: 7331, maxBodyBytes: 1048576, + // Optional custom data viewer links shown in discovery and manifest output. + viewerLinks: [ + // { label: 'My Viewer', href: 'http://127.0.0.1:5173/jsondb' }, + ], + }, + + // REST and manifest response formats. Built-ins are json, html, and md. + // Object entries can register media types for Accept negotiation and can + // render both resource routes and /__jsondb/manifest.. + rest: { + formats: { + default: 'json', + // yaml: { + // mediaTypes: ['application/yaml', 'text/yaml'], + // contentType: 'application/yaml; charset=utf-8', + // render({ data }) { + // return stringifyYaml(data); + // }, + // }, + }, }, // Local latency is on by default so loading states are visible. Use 0 to diff --git a/src/cli/commands/viewer.js b/src/cli/commands/viewer.js new file mode 100644 index 0000000..5766a14 --- /dev/null +++ b/src/cli/commands/viewer.js @@ -0,0 +1,38 @@ +import path from 'node:path'; +import { jsonDbError } from '../../errors.js'; +import { loadProjectSchema } from '../../schema.js'; +import { generateViewerManifest } from '../../viewer-manifest.js'; +import { isHelpRequested, valueAfter } from '../args.js'; +import { printViewerHelp } from '../output.js'; + +export async function runViewer(config, args) { + if (isHelpRequested(args)) { + printViewerHelp(); + return; + } + + if (args[0] !== 'manifest') { + throw jsonDbError( + 'VIEWER_UNKNOWN_COMMAND', + `Unknown viewer command "${args[0] ?? ''}".`, + { + hint: 'Use jsondb viewer manifest [--out ].', + }, + ); + } + + const project = await loadProjectSchema(config); + const result = await generateViewerManifest(config, { + project, + outFile: valueAfter(args, '--out'), + }); + + if (result.outFiles.length === 0) { + console.log(result.content); + return; + } + + for (const filePath of result.outFiles) { + console.log(`Generated ${path.relative(config.cwd, filePath)}`); + } +} diff --git a/src/cli/index.js b/src/cli/index.js index ba0b103..d2f44b3 100644 --- a/src/cli/index.js +++ b/src/cli/index.js @@ -8,7 +8,8 @@ import { runSchema } from './commands/schema.js'; import { runServe } from './commands/serve.js'; import { runSync } from './commands/sync.js'; import { runTypes } from './commands/types.js'; -import { printDiagnostic, printDoctorHelp, printGenerateHelp, printHelp, printSchemaHelp, printServeHelp, printTypesHelp } from './output.js'; +import { runViewer } from './commands/viewer.js'; +import { printDiagnostic, printDoctorHelp, printGenerateHelp, printHelp, printSchemaHelp, printServeHelp, printTypesHelp, printViewerHelp } from './output.js'; export async function main(args = process.argv.slice(2)) { const command = args[0] ?? 'help'; @@ -49,6 +50,9 @@ export async function main(args = process.argv.slice(2)) { case 'serve': await runServe(config, args.slice(1)); break; + case 'viewer': + await runViewer(config, args.slice(1)); + break; case 'generate': await runGenerate(config, args.slice(1)); break; @@ -76,6 +80,9 @@ function printSubcommandHelp(command, args) { case 'serve': printServeHelp(); return true; + case 'viewer': + printViewerHelp(); + return true; case 'generate': printGenerateHelp(generateHelpUsage(args)); return true; diff --git a/src/cli/output.js b/src/cli/output.js index f93da2b..d597704 100644 --- a/src/cli/output.js +++ b/src/cli/output.js @@ -30,6 +30,7 @@ Usage: jsondb schema bundle [--out ] [--force] jsondb schema manifest [--out ] jsondb schema validate + jsondb viewer manifest [--out ] jsondb doctor [--strict] [--json] jsondb check [--strict] [--json] jsondb create @@ -93,6 +94,19 @@ Options: `); } +export function printViewerHelp() { + console.log(`jsondb viewer + +Usage: + jsondb viewer manifest [--out ] + +Options: + --out Write generated viewer manifest output to this path + --cwd Project directory + --config Config file path +`); +} + export function printServeHelp() { console.log(`jsondb serve diff --git a/src/client.js b/src/client.js index 1a9c8f6..2348a5e 100644 --- a/src/client.js +++ b/src/client.js @@ -2,10 +2,11 @@ import { jsonDbError } from './errors.js'; export function createJsonDbClient(options = {}) { const baseUrl = options.baseUrl ?? ''; + const apiBase = normalizeBasePath(options.apiBase ?? '/__jsondb'); const forkPaths = forkPathsForOptions(options); const restBasePath = options.restBasePath ?? forkPaths.restBasePath ?? ''; const graphqlPath = options.graphqlPath ?? forkPaths.graphqlPath ?? '/graphql'; - const restBatchPath = options.restBatchPath ?? forkPaths.restBatchPath ?? '/__jsondb/batch'; + const restBatchPath = options.restBatchPath ?? forkPaths.restBatchPath ?? `${apiBase}/batch`; const batching = normalizeBatching(options.batching); const graphqlQueue = createQueue((requests) => graphqlBatch(requests), batching, { @@ -93,7 +94,8 @@ function forkPathsForOptions(options) { } const fork = normalizeForkName(options.fork); - const base = `/__jsondb/forks/${encodeURIComponent(fork)}`; + const apiBase = normalizeBasePath(options.apiBase ?? '/__jsondb'); + const base = `${apiBase}/forks/${encodeURIComponent(fork)}`; return { restBasePath: `${base}/rest`, restBatchPath: `${base}/batch`, @@ -359,6 +361,11 @@ function joinPaths(basePath, requestPath) { return `${normalizedBase}${normalizedPath === '/' ? '' : normalizedPath}`; } +function normalizeBasePath(value) { + const path = `/${String(value ?? '').replace(/^\/+/, '').replace(/\/+$/, '')}`; + return path === '/' ? '' : path; +} + function stableStringify(value) { if (Array.isArray(value)) { return `[${value.map(stableStringify).join(',')}]`; diff --git a/src/client.test.js b/src/client.test.js index 0dd4da7..e721bd7 100644 --- a/src/client.test.js +++ b/src/client.test.js @@ -198,6 +198,41 @@ test('client can batch REST requests', async () => { assert.equal(calls[0].url, 'http://jsondb.local/__jsondb/batch'); }); +test('client apiBase customizes default REST batch path without changing REST or GraphQL defaults', async () => { + const calls = withMockFetch([ + { + status: 200, + headers: {}, + body: [{ id: 'u_1' }], + }, + [ + { + status: 200, + headers: {}, + body: [{ id: 'u_1' }], + }, + ], + { + data: { + users: [{ id: 'u_1' }], + }, + }, + ]); + + const client = createJsonDbClient({ + baseUrl: 'http://jsondb.local', + apiBase: '/_jsondb', + }); + + await client.rest.get('/users'); + await client.rest.batch([{ method: 'GET', path: '/users' }]); + await client.graphql('{ users { id } }'); + + assert.equal(calls[0].url, 'http://jsondb.local/users'); + assert.equal(calls[1].url, 'http://jsondb.local/_jsondb/batch'); + assert.equal(calls[2].url, 'http://jsondb.local/graphql'); +}); + test('client can target scoped REST base paths for Vite dev APIs', async () => { const calls = withMockFetch([ { @@ -266,6 +301,42 @@ test('client fork option derives scoped REST, batch, and GraphQL paths', async ( assert.equal(calls[2].url, 'http://jsondb.local/__jsondb/forks/legacy-demo/graphql'); }); +test('client apiBase option customizes default fork paths', async () => { + const calls = withMockFetch([ + { + status: 200, + headers: {}, + body: [{ id: 'u_legacy' }], + }, + [ + { + status: 200, + headers: {}, + body: [{ id: 'u_legacy' }], + }, + ], + { + data: { + users: [{ id: 'u_legacy' }], + }, + }, + ]); + + const client = createJsonDbClient({ + baseUrl: 'http://jsondb.local', + apiBase: '/_jsondb', + fork: 'legacy-demo', + }); + + await client.rest.get('/users'); + await client.rest.batch([{ method: 'GET', path: '/users' }]); + await client.graphql('{ users { id } }'); + + assert.equal(calls[0].url, 'http://jsondb.local/_jsondb/forks/legacy-demo/rest/users'); + assert.equal(calls[1].url, 'http://jsondb.local/_jsondb/forks/legacy-demo/batch'); + assert.equal(calls[2].url, 'http://jsondb.local/_jsondb/forks/legacy-demo/graphql'); +}); + test('client fork option rejects unsafe fork names', () => { assert.throws( () => createJsonDbClient({ fork: '../legacy-demo' }), diff --git a/src/features/config/defaults.js b/src/features/config/defaults.js index bd9d0c3..d4774a3 100644 --- a/src/features/config/defaults.js +++ b/src/features/config/defaults.js @@ -3,6 +3,7 @@ export const DEFAULT_CONFIG = { sourceDir: './db', stateDir: './.jsondb', schemaOutFile: null, + viewerManifestOutFile: null, schemaManifest: {}, sources: { readers: [], @@ -43,9 +44,11 @@ export const DEFAULT_CONFIG = { naming: 'basename', }, server: { + apiBase: '/__jsondb', host: '127.0.0.1', port: 7331, maxBodyBytes: 1048576, + viewerLinks: [], }, rest: { enabled: true, diff --git a/src/features/config/load.js b/src/features/config/load.js index dec9b33..8429c9d 100644 --- a/src/features/config/load.js +++ b/src/features/config/load.js @@ -48,6 +48,10 @@ export async function loadConfig(options = {}) { merged.schemaOutFile = resolveFrom(cwd, merged.schemaOutFile); } + if (merged.viewerManifestOutFile) { + merged.viewerManifestOutFile = resolveFrom(cwd, merged.viewerManifestOutFile); + } + merged.forks = normalizeForks(merged, merged.forks); return merged; diff --git a/src/features/sync/index.js b/src/features/sync/index.js index 97e6895..67239e0 100644 --- a/src/features/sync/index.js +++ b/src/features/sync/index.js @@ -3,6 +3,7 @@ import path from 'node:path'; import { loadProjectSchema, makeGeneratedSchema } from '../../schema.js'; import { generateSchemaManifest } from '../../schema-manifest.js'; import { generateTypes } from '../../types.js'; +import { generateViewerManifest } from '../../viewer-manifest.js'; import { readJsonState, writeJsonState } from '../runtime/state.js'; import { createRuntime } from '../storage/runtime.js'; import { writeSourceMetadata } from '../storage/source.js'; @@ -51,6 +52,13 @@ export async function syncJsonFixtureDb(config, options = {}) { } } + if (config.viewerManifestOutFile) { + const result = await generateViewerManifest(config, { project }); + for (const outFile of result.outFiles) { + logs.push(`Generated ${path.relative(config.cwd, outFile)}`); + } + } + const sourceMetadataPath = path.join(config.stateDir, 'state', '.sources.json'); const sourceMetadata = await readJsonState(sourceMetadataPath, { resources: {} }); sourceMetadata.resources ??= {}; diff --git a/src/features/viewer/manifest.js b/src/features/viewer/manifest.js new file mode 100644 index 0000000..e9f858f --- /dev/null +++ b/src/features/viewer/manifest.js @@ -0,0 +1,177 @@ +import { resolveFrom, writeText } from '../../fs-utils.js'; +import { restFormatMetadata } from '../../rest/formats.js'; +import { loadProjectSchema } from '../schema/project.js'; +import { renderSchemaManifest } from '../schema/manifest.js'; + +export async function generateViewerManifest(config, options = {}) { + const project = options.project ?? await loadProjectSchema(config); + const manifest = renderViewerManifest(project.resources, config, { + diagnostics: project.diagnostics, + generatedAt: options.generatedAt, + routes: options.routes, + }); + const content = `${JSON.stringify(manifest, null, 2)}\n`; + const outFiles = outputFiles(config, options); + + for (const outFile of outFiles) { + await writeText(outFile, content); + } + + return { + manifest, + content, + outFiles, + diagnostics: project.diagnostics, + }; +} + +export function renderViewerManifest(resources, config = {}, options = {}) { + const schemaManifest = renderSchemaManifest(resources, config); + const routes = normalizeViewerRoutes(config, options.routes); + const resourceList = [...resources]; + const collections = resourceBucketManifest(schemaManifest.collections, resourceList, routes); + const documents = resourceBucketManifest(schemaManifest.documents, resourceList, routes); + + return { + version: 1, + kind: 'jsondb.viewerManifest', + generatedAt: options.generatedAt ?? new Date().toISOString(), + api: { + viewer: routes.viewerPath, + manifest: routes.manifestPath, + manifestJson: routes.manifestJsonPath, + manifestHtml: routes.manifestHtmlPath, + manifestMarkdown: routes.manifestMarkdownPath, + formats: restFormatMetadata(config, routes), + viewers: viewerLinks(config, routes.viewerPath), + schema: routes.schemaPath, + events: routes.eventsPath, + batch: routes.batchPath, + import: routes.importPath, + graphql: routes.graphqlPath, + restBasePath: routes.restBasePath ?? '', + resources: Object.fromEntries(resourceList.map((resource) => [resource.name, resourceApi(resource, routes)])), + }, + capabilities: { + collections: resourceList.some((resource) => resource.kind === 'collection'), + documents: resourceList.some((resource) => resource.kind === 'document'), + writes: config.rest?.enabled !== false, + restBatch: true, + graphql: config.graphql?.enabled !== false, + csvImport: true, + liveEvents: true, + }, + collections, + documents, + diagnostics: options.diagnostics ?? [], + }; +} + +function outputFiles(config, options) { + const outFile = options.outFile + ? resolveFrom(config.cwd, options.outFile) + : config.viewerManifestOutFile; + return outFile ? [outFile] : []; +} + +function resourceBucketManifest(bucket = {}, resources, routes) { + return Object.fromEntries(Object.entries(bucket).map(([resourceName, manifest]) => { + const resource = resources.find((candidate) => candidate.name === resourceName); + if (!resource) { + return [resourceName, manifest]; + } + + return [resourceName, { + ...manifest, + typeName: resource.typeName, + routePath: resource.routePath, + api: resourceApi(resource, routes), + relations: resource.relations ?? [], + }]; + })); +} + +function resourceApi(resource, routes) { + const route = joinPaths(routes.restBasePath ?? '', resource.routePath); + if (resource.kind === 'document') { + return { + kind: 'document', + read: route, + write: route, + }; + } + + return { + kind: 'collection', + list: route, + record: `${route}/{${resource.idField ?? 'id'}}`, + }; +} + +function normalizeViewerRoutes(config, routes = {}) { + const apiBase = normalizeBasePath(routes.apiBase ?? config.server?.apiBase ?? '/__jsondb'); + const restBasePath = routes.restBasePath === null + ? '' + : normalizeBasePath(routes.restBasePath ?? ''); + + return { + apiBase, + viewerPath: routes.viewerPath ?? apiBase, + manifestPath: routes.manifestPath ?? `${apiBase}/manifest`, + manifestJsonPath: routes.manifestJsonPath ?? `${apiBase}/manifest.json`, + manifestHtmlPath: routes.manifestHtmlPath ?? `${apiBase}/manifest.html`, + manifestMarkdownPath: routes.manifestMarkdownPath ?? `${apiBase}/manifest.md`, + schemaPath: routes.schemaPath ?? `${apiBase}/schema`, + eventsPath: routes.eventsPath ?? `${apiBase}/events`, + batchPath: routes.batchPath ?? `${apiBase}/batch`, + importPath: routes.importPath ?? `${apiBase}/import`, + graphqlPath: routes.graphqlPath ?? config.graphql?.path ?? '/graphql', + restBasePath, + }; +} + +function viewerLinks(config, viewerPath) { + const configuredLinks = Array.isArray(config.server?.viewerLinks) + ? config.server.viewerLinks + : []; + return [ + { + label: 'Data Viewer', + href: viewerPath, + source: 'built-in', + }, + ...configuredLinks.map(normalizeViewerLink).filter(Boolean), + ]; +} + +function normalizeViewerLink(link) { + if (!link || typeof link !== 'object') { + return null; + } + + const href = typeof link.href === 'string' ? link.href : link.url; + if (typeof href !== 'string' || href.trim() === '') { + return null; + } + + return { + label: typeof link.label === 'string' && link.label.trim() ? link.label : 'Custom Viewer', + href, + source: 'custom', + }; +} + +function joinPaths(basePath, routePath) { + if (!basePath) { + return routePath; + } + + const base = `/${String(basePath).replace(/^\/+/, '').replace(/\/+$/, '')}`; + const route = `/${String(routePath || '/').replace(/^\/+/, '')}`; + return `${base}${route === '/' ? '' : route}`; +} + +function normalizeBasePath(value) { + const pathValue = `/${String(value ?? '').replace(/^\/+/, '').replace(/\/+$/, '')}`; + return pathValue === '/' ? '' : pathValue; +} diff --git a/src/index.d.ts b/src/index.d.ts index 005d57f..e06a262 100644 --- a/src/index.d.ts +++ b/src/index.d.ts @@ -206,8 +206,9 @@ export type JsonDbSourcesOptions = { writePolicy?: 'preserve' | 'allow'; }; -export type JsonDbRestFormatContext = { +export type JsonDbRestResourceFormatContext = { db: unknown; + target?: 'resource'; resource: Record; resourceName: string; data: unknown; @@ -216,6 +217,20 @@ export type JsonDbRestFormatContext = { url: URL; }; +export type JsonDbRestManifestFormatContext = { + db: unknown; + target?: 'manifest'; + data: unknown; + manifest: unknown; + format: string; + request: IncomingMessage | Record; + url: URL; + routes?: Record; +}; + +export type JsonDbRestFormatContext = JsonDbRestResourceFormatContext; +export type JsonDbRestAnyFormatContext = JsonDbRestResourceFormatContext | JsonDbRestManifestFormatContext; + export type JsonDbRestFormatResult = string | Buffer | { status?: number; body?: string | Buffer; @@ -224,6 +239,21 @@ export type JsonDbRestFormatResult = string | Buffer | { }; export type JsonDbRestFormatRenderer = (context: JsonDbRestFormatContext) => JsonDbRestFormatResult | Promise; +export type JsonDbRestAnyFormatRenderer = (context: JsonDbRestAnyFormatContext) => JsonDbRestFormatResult | Promise; +export type JsonDbRestManifestFormatRenderer = (context: JsonDbRestManifestFormatContext) => JsonDbRestFormatResult | Promise; + +export type JsonDbRestFormatDefinition = { + /** Media types used by extensionless Accept negotiation. */ + mediaTypes?: string | string[]; + /** Default response content type when the renderer returns a string or Buffer. */ + contentType?: string; + /** Generic renderer used for resource and manifest responses unless a target-specific renderer is provided. */ + render?: JsonDbRestAnyFormatRenderer; + /** Renderer for REST resource routes such as /users.yaml. */ + renderResource?: JsonDbRestFormatRenderer; + /** Renderer for viewer manifest routes such as /__jsondb/manifest.yaml. */ + renderManifest?: JsonDbRestManifestFormatRenderer; +}; export type JsonDbOptions = { /** Project root used to resolve relative config paths. Defaults to process.cwd(). */ @@ -238,6 +268,8 @@ export type JsonDbOptions = { stateDir?: string; /** Optional committed generated JSON schema manifest for admin/CMS UI generation. */ schemaOutFile?: string | null; + /** Optional committed generated JSON viewer manifest for custom data UIs. */ + viewerManifestOutFile?: string | null; /** Optional visitor hooks for customizing generated schema manifest output. */ schemaManifest?: JsonDbSchemaManifestOptions; /** Optional source readers for custom schema or data file formats. */ @@ -280,18 +312,25 @@ export type JsonDbOptions = { /** Public storage options. Defaults to the JSON store. */ stores?: JsonDbStoresOptions; server?: { + /** Scoped base for local jsondb dev tools. Defaults to "/__jsondb". */ + apiBase?: string; /** Local HTTP host. Defaults to "127.0.0.1". */ host?: string; /** Local HTTP port. Defaults to 7331. */ port?: number; /** Maximum JSON request body size in bytes. Defaults to 1048576. */ maxBodyBytes?: number; + /** Optional links to custom data viewers shown in discovery and the viewer manifest. */ + viewerLinks?: Array<{ + label?: string; + href: string; + }>; }; rest?: { /** Enable generated REST routes. */ enabled?: boolean; /** GET response formats by extension. "default" controls extensionless resource routes. */ - formats?: Record; + formats?: Record; }; graphql?: { /** Enable the focused dependency-free GraphQL endpoint. */ @@ -392,6 +431,8 @@ export type RestBatchResult = { export type JsonDbClientOptions = { baseUrl?: string; + /** Scoped base for default batch and fork paths. Defaults to "/__jsondb". */ + apiBase?: string; /** Target a configured database fork, such as "legacy-demo". */ fork?: string; restBasePath?: string; @@ -481,6 +522,8 @@ export function syncJsonFixtureDb(config: JsonDbOptions, options?: { allowErrors export function generateTypes(config: JsonDbOptions, options?: { outFile?: string }): Promise<{ content: string; outFiles: string[] }>; export function generateSchemaManifest(config: JsonDbOptions, options?: { outFile?: string }): Promise<{ manifest: unknown; content: string; outFiles: string[] }>; export function renderSchemaManifest(resources: unknown[], config?: JsonDbOptions): unknown; +export function generateViewerManifest(config: JsonDbOptions, options?: { outFile?: string }): Promise<{ manifest: unknown; content: string; outFiles: string[] }>; +export function renderViewerManifest(resources: unknown[], config?: JsonDbOptions): unknown; export function mergeManifest(base: unknown, patch: unknown): unknown; export function resourceNameFromPath(file: string, options?: { strategy?: JsonDbResourceNamingStrategy }): string; export function parseFixturePath(file: string): { diff --git a/src/index.js b/src/index.js index 9ffa468..731e5a6 100644 --- a/src/index.js +++ b/src/index.js @@ -7,6 +7,7 @@ export { executeGraphql, executeGraphqlBatch, parseGraphql } from './graphql/ind export { generateHonoStarter, renderHonoStarter } from './generate/hono.js'; export { loadProjectSchema, makeGeneratedSchema } from './schema.js'; export { generateSchemaManifest, renderSchemaManifest } from './schema-manifest.js'; +export { generateViewerManifest, renderViewerManifest } from './viewer-manifest.js'; export { createJsonDbRequestHandler, startJsonDbServer } from './server.js'; export { syncJsonFixtureDb } from './sync.js'; export { generateTypes, renderTypes } from './types.js'; diff --git a/src/integrations/vite.js b/src/integrations/vite.js index 35090b5..559567c 100644 --- a/src/integrations/vite.js +++ b/src/integrations/vite.js @@ -60,7 +60,7 @@ export function jsondbPlugin(options = {}) { } function resolveViteRoutes(options) { - const apiBase = normalizeBasePath(options.apiBase ?? '/__jsondb'); + const apiBase = normalizeBasePath(options.apiBase ?? options.server?.apiBase ?? '/__jsondb'); return { apiBase, rootRoutes: options.rootRoutes === true, diff --git a/src/mock.test.js b/src/mock.test.js index e85d215..28693e0 100644 --- a/src/mock.test.js +++ b/src/mock.test.js @@ -44,3 +44,29 @@ test('mock errors can force chaos responses', async () => { }, }); }); + +test('mock behavior skips the configured viewer apiBase', async () => { + const config = { + server: { + apiBase: '/_jsondb', + }, + mock: { + delay: [0, 0], + errors: { + rate: 1, + status: 599, + message: 'forced chaos', + }, + }, + }; + + assert.equal(await runMockBehavior(config, new URL('http://jsondb.local/_jsondb')), null); + + assert.deepEqual(await runMockBehavior(config, new URL('http://jsondb.local/__jsondb')), { + status: 599, + body: { + error: 'forced chaos', + mock: true, + }, + }); +}); diff --git a/src/rest/formats.js b/src/rest/formats.js new file mode 100644 index 0000000..529c6df --- /dev/null +++ b/src/rest/formats.js @@ -0,0 +1,362 @@ +import { renderJsonViewer } from '../web/json-viewer.js'; + +const BUILT_IN_FORMATS = { + json: { + mediaTypes: ['application/json'], + contentType: 'application/json; charset=utf-8', + renderResource: ({ data }) => `${JSON.stringify(data, null, 2)}\n`, + renderManifest: ({ data }) => `${JSON.stringify(data, null, 2)}\n`, + }, + html: { + mediaTypes: ['text/html'], + contentType: 'text/html; charset=utf-8', + renderResource: ({ data, url }) => renderJsonViewer(data, { title: jsonViewerTitle(url) }), + renderManifest: ({ data }) => renderJsonViewer(data, { title: 'jsondb viewer manifest' }), + }, + md: { + mediaTypes: ['text/markdown'], + contentType: 'text/markdown; charset=utf-8', + renderResource: ({ resource, data }) => renderMarkdownJson(resource.name, data, [ + ['Resource', resource.name], + ['Kind', resource.kind], + ['Route', resource.routePath], + ]), + renderManifest: ({ data, routes }) => renderMarkdownJson('jsondb viewer manifest', data, [ + ['Kind', 'jsondb.viewerManifest'], + ['JSON', routes?.manifestJsonPath ?? manifestPathForFormat(routes, 'json')], + ]), + }, +}; + +export function normalizeRestFormatRegistry(config = {}) { + const configuredFormats = config.rest?.formats ?? {}; + const keys = new Set([ + ...Object.keys(BUILT_IN_FORMATS), + ...Object.keys(configuredFormats).filter((key) => key !== 'default'), + ]); + const registry = {}; + + for (const key of keys) { + registry[key] = normalizeFormatDefinition(key, configuredFormats[key], BUILT_IN_FORMATS[key]); + } + + return registry; +} + +export function negotiateRestFormat(config, request, target = 'resource') { + const accept = headerValue(request, 'accept'); + const fallback = defaultFormatKey(config, target); + if (!accept) { + return fallback; + } + + const preferences = parseAcceptHeader(accept); + if (preferences.length === 0) { + return fallback; + } + + const registry = normalizeRestFormatRegistry(config); + let bestFormat = null; + let bestScore = { + quality: 0, + specificity: -1, + index: Number.MAX_SAFE_INTEGER, + }; + + for (const [format, definition] of Object.entries(registry)) { + if (!rendererForTarget(definition, target)) { + continue; + } + + for (const mediaType of definition.mediaTypes ?? []) { + const score = acceptedMediaScore(preferences, mediaType); + if (compareAcceptScores(score, bestScore) > 0) { + bestFormat = format; + bestScore = score; + } + } + } + + return bestScore.quality > 0 ? bestFormat : fallback; +} + +export function resolveRestFormat(config, format, target = 'resource') { + const registry = normalizeRestFormatRegistry(config); + const key = format ?? defaultFormatKey(config, target); + return resolveRestFormatFromRegistry(config, registry, key, target); +} + +export function availableRestFormats(config = {}, target = null) { + const registry = normalizeRestFormatRegistry(config); + return Object.keys(registry) + .filter((format) => !target || resolveRestFormat(config, format, target)?.renderer) + .sort(); +} + +export function restFormatMetadata(config = {}, routes = {}) { + const registry = normalizeRestFormatRegistry(config); + return Object.fromEntries(availableRestFormats(config).map((format) => { + const definition = registry[format]; + return [format, { + extension: `.${format}`, + mediaTypes: definition.mediaTypes ?? [], + contentType: definition.contentType ?? contentTypeForMediaTypes(definition.mediaTypes), + manifestPath: manifestPathForFormat(routes, format), + }]; + })); +} + +export function manifestPathForFormat(routes = {}, format) { + if (format === 'json' && routes.manifestJsonPath) { + return routes.manifestJsonPath; + } + + if (format === 'html' && routes.manifestHtmlPath) { + return routes.manifestHtmlPath; + } + + if (format === 'md' && routes.manifestMarkdownPath) { + return routes.manifestMarkdownPath; + } + + return `${routes.manifestPath ?? '/__jsondb/manifest'}.${format}`; +} + +export function renderMarkdownJson(title, value, metadata = []) { + return [ + `# ${title}`, + '', + ...metadata.map(([label, detail]) => `- ${label}: \`${escapeMarkdownInline(detail)}\``), + '', + '```json', + JSON.stringify(value, null, 2), + '```', + '', + ].join('\n'); +} + +function normalizeFormatDefinition(key, configured, fallback) { + const base = fallback ?? {}; + + if (typeof configured === 'string') { + return { + key, + alias: configured, + mediaTypes: normalizeMediaTypes(base.mediaTypes), + contentType: base.contentType ?? contentTypeForMediaTypes(base.mediaTypes), + }; + } + + if (typeof configured === 'function') { + return { + key, + mediaTypes: normalizeMediaTypes(base.mediaTypes), + contentType: base.contentType ?? contentTypeForMediaTypes(base.mediaTypes), + renderResource: configured, + renderManifest: base.renderManifest, + }; + } + + if (configured && typeof configured === 'object' && !Array.isArray(configured)) { + const mediaTypes = normalizeMediaTypes(configured.mediaTypes ?? base.mediaTypes); + const genericRenderer = typeof configured.render === 'function' + ? configured.render + : null; + return { + key, + mediaTypes, + contentType: configured.contentType ?? base.contentType ?? contentTypeForMediaTypes(mediaTypes), + renderResource: typeof configured.renderResource === 'function' + ? configured.renderResource + : genericRenderer ?? base.renderResource, + renderManifest: typeof configured.renderManifest === 'function' + ? configured.renderManifest + : genericRenderer ?? base.renderManifest, + }; + } + + return { + key, + mediaTypes: normalizeMediaTypes(base.mediaTypes), + contentType: base.contentType ?? contentTypeForMediaTypes(base.mediaTypes), + renderResource: base.renderResource, + renderManifest: base.renderManifest, + }; +} + +function resolveRestFormatFromRegistry(config, registry, key, target, seen = new Set()) { + const normalizedKey = normalizeFormatKey(key); + if (!normalizedKey) { + return null; + } + + if (seen.has(normalizedKey)) { + return null; + } + seen.add(normalizedKey); + + if (normalizedKey === 'default') { + const configured = config.rest?.formats?.default; + if (typeof configured === 'string') { + return resolveRestFormatFromRegistry(config, registry, configured, target, seen); + } + + if (typeof configured === 'function') { + return target === 'resource' + ? { + key: 'default', + contentType: 'text/plain; charset=utf-8', + renderer: configured, + } + : resolveRestFormatFromRegistry(config, registry, 'json', target, seen); + } + + if (configured && typeof configured === 'object' && !Array.isArray(configured)) { + const definition = normalizeFormatDefinition('default', configured, null); + const renderer = rendererForTarget(definition, target); + if (renderer) { + return { + key: 'default', + contentType: definition.contentType, + renderer, + }; + } + } + + return resolveRestFormatFromRegistry(config, registry, 'json', target, seen); + } + + const definition = registry[normalizedKey]; + if (!definition) { + return null; + } + + if (definition.alias) { + return resolveRestFormatFromRegistry(config, registry, definition.alias, target, seen); + } + + const renderer = rendererForTarget(definition, target); + if (!renderer) { + return null; + } + + return { + key: normalizedKey, + contentType: definition.contentType ?? contentTypeForMediaTypes(definition.mediaTypes), + renderer, + }; +} + +function defaultFormatKey(config, target) { + const configured = config.rest?.formats?.default; + if (typeof configured === 'string') { + return configured; + } + + if (target === 'resource' && (typeof configured === 'function' || configured)) { + return 'default'; + } + + return 'json'; +} + +function rendererForTarget(definition, target) { + return target === 'manifest' ? definition.renderManifest : definition.renderResource; +} + +function normalizeFormatKey(value) { + return String(value ?? '').replace(/^\./, '').trim(); +} + +function normalizeMediaTypes(value) { + if (!value) { + return []; + } + + const values = Array.isArray(value) ? value : [value]; + return values + .filter((item) => typeof item === 'string' && item.trim()) + .map((item) => item.trim().toLowerCase()); +} + +function contentTypeForMediaTypes(mediaTypes = []) { + return mediaTypes[0] ? `${mediaTypes[0]}; charset=utf-8` : 'text/plain; charset=utf-8'; +} + +function jsonViewerTitle(url) { + if (!url?.pathname) { + return 'jsondb JSON'; + } + + return url.pathname.split('/').filter(Boolean).pop() ?? 'jsondb JSON'; +} + +function escapeMarkdownInline(value) { + return String(value).replaceAll('`', '\\`'); +} + +function headerValue(request, name) { + if (typeof request.headers?.get === 'function') { + return request.headers.get(name); + } + + return request.headers?.[name] ?? request.headers?.[name.toLowerCase()]; +} + +function parseAcceptHeader(value) { + return String(value).split(',').map((entry, index) => { + const [mediaRange, ...parameters] = entry.trim().split(';'); + let quality = 1; + for (const parameter of parameters) { + const [name, rawValue] = parameter.trim().split('='); + if (name?.toLowerCase() === 'q') { + const parsed = Number(rawValue); + quality = Number.isFinite(parsed) ? Math.min(1, Math.max(0, parsed)) : 0; + } + } + + return { + index, + mediaRange: mediaRange.toLowerCase(), + quality, + }; + }).filter((preference) => preference.mediaRange.includes('/')); +} + +function acceptedMediaScore(preferences, mediaType) { + const [wantedType, wantedSubtype] = mediaType.split('/'); + let best = { + quality: 0, + specificity: -1, + index: Number.MAX_SAFE_INTEGER, + }; + + for (const preference of preferences) { + const [type, subtype] = preference.mediaRange.split('/'); + if ((type !== '*' && type !== wantedType) || (subtype !== '*' && subtype !== wantedSubtype)) { + continue; + } + + const specificity = Number(type !== '*') + Number(subtype !== '*'); + const candidate = { + quality: preference.quality, + specificity, + index: preference.index, + }; + if (compareAcceptScores(candidate, best) > 0) { + best = candidate; + } + } + + return best; +} + +function compareAcceptScores(left, right) { + if (left.quality !== right.quality) { + return left.quality - right.quality; + } + if (left.specificity !== right.specificity) { + return left.specificity - right.specificity; + } + return right.index - left.index; +} diff --git a/src/rest/handler.js b/src/rest/handler.js index 642ff78..bdd3fca 100644 --- a/src/rest/handler.js +++ b/src/rest/handler.js @@ -5,7 +5,9 @@ import { jsonDbError, listChoices, serializeError } from '../errors.js'; import { resolveResource, resourceNameCandidates } from '../names.js'; import { makeGeneratedSchema } from '../schema.js'; import { syncJsonFixtureDb } from '../sync.js'; +import { renderViewerManifest } from '../viewer-manifest.js'; import { renderJsonDbViewer } from '../web/viewer.js'; +import { availableRestFormats, negotiateRestFormat, resolveRestFormat, restFormatMetadata } from './formats.js'; import { shapeCollectionRead } from './shape.js'; export async function handleRestRequest(db, request, response, url = new URL(request.url, 'http://jsondb.local'), options = {}) { @@ -23,6 +25,7 @@ async function handleRestRequestUnsafe(db, request, response, url, options) { sendText(response, 200, renderJsonDbViewer({ graphqlPath: routeOptions.graphqlPath, schemaPath: routeOptions.schemaPath, + manifestPath: routeOptions.manifestJsonPath, eventsPath: routeOptions.eventsPath, importPath: routeOptions.importPath, restBatchPath: routeOptions.batchPath, @@ -50,12 +53,42 @@ async function handleRestRequestUnsafe(db, request, response, url, options) { return; } + const manifestFormat = request.method === 'GET' + ? manifestResponseFormat(url, request, routeOptions, db.config) + : null; + if (manifestFormat) { + const manifest = renderViewerManifest([...db.resources.values()], db.config, { + diagnostics: db.diagnostics ?? [], + routes: routeOptions, + }); + + const resolved = resolveRestFormat(db.config, manifestFormat, 'manifest'); + if (!resolved) { + sendUnknownFormat(response, manifestFormat, db.config, 'manifest'); + return; + } + + const result = await resolved.renderer({ + db, + data: manifest, + manifest, + format: resolved.key, + request, + url, + routes: routeOptions, + target: 'manifest', + }); + const normalized = normalizeFormatResult(result, resolved.contentType); + sendText(response, normalized.status, normalized.body, normalized.contentType); + return; + } + const resourceUrl = restResourceUrl(url, routeOptions); const [rawRouteName, rawId] = resourceUrl.pathname.split('/').filter(Boolean); const { routeName, id, format } = parseFormattedResourcePath(rawRouteName, rawId); if (!routeName) { const discovery = rootDiscovery(db, routeOptions); - if (request.method === 'GET' && requestPrefersHtml(request)) { + if (request.method === 'GET' && requestPrefersHtml(db.config, request)) { sendText(response, 200, renderRootDiscovery(discovery), 'text/html; charset=utf-8'); return; } @@ -132,13 +165,14 @@ export function findResourceByRoute(db, routeName) { export async function executeRestBatch(db, body, options = {}) { const requests = Array.isArray(body) ? body : body.requests; + const batchPath = batchPathForOptions(options, db); if (!Array.isArray(requests)) { throw jsonDbError( 'REST_BATCH_INVALID_BODY', 'REST batch body must be an array or an object with a requests array.', { status: 400, - hint: 'Send POST /__jsondb/batch with [{ "method": "GET", "path": "/users" }].', + hint: `Send POST ${batchPath} with [{ "method": "GET", "path": "/users" }].`, details: { receivedType: body === null ? 'null' : Array.isArray(body) ? 'array' : typeof body, }, @@ -219,10 +253,14 @@ function maxBodyBytes(db) { } function normalizeRestRouteOptions(db, options = {}) { - const apiBase = normalizeBasePath(options.apiBase ?? '/__jsondb'); + const apiBase = normalizeBasePath(options.apiBase ?? db.config.server?.apiBase ?? '/__jsondb'); return { apiBase, viewerPath: options.viewerPath ?? apiBase, + manifestPath: options.manifestPath ?? `${apiBase}/manifest`, + manifestJsonPath: options.manifestJsonPath ?? `${apiBase}/manifest.json`, + manifestHtmlPath: options.manifestHtmlPath ?? `${apiBase}/manifest.html`, + manifestMarkdownPath: options.manifestMarkdownPath ?? `${apiBase}/manifest.md`, schemaPath: options.schemaPath ?? `${apiBase}/schema`, batchPath: options.batchPath ?? `${apiBase}/batch`, importPath: options.importPath ?? `${apiBase}/import`, @@ -258,17 +296,41 @@ function sourceDirLabel(config) { } function rootDiscovery(db, options = {}) { - const schemaPath = options.schemaPath ?? '/__jsondb/schema'; - const viewerPath = options.viewerPath ?? '/__jsondb'; + const apiBase = normalizeBasePath(options.apiBase ?? db.config.server?.apiBase ?? '/__jsondb'); + const schemaPath = options.schemaPath ?? `${apiBase}/schema`; + const manifestPath = options.manifestPath ?? `${apiBase}/manifest`; + const manifestJsonPath = options.manifestJsonPath ?? `${apiBase}/manifest.json`; + const manifestHtmlPath = options.manifestHtmlPath ?? `${apiBase}/manifest.html`; + const manifestMarkdownPath = options.manifestMarkdownPath ?? `${apiBase}/manifest.md`; + const viewerPath = options.viewerPath ?? apiBase; const graphqlPath = options.graphqlPath ?? db.config.graphql?.path ?? '/graphql'; + const viewers = viewerLinks(db.config, viewerPath); + const formats = restFormatMetadata(db.config, { + manifestPath, + manifestJsonPath, + manifestHtmlPath, + manifestMarkdownPath, + }); return { resources: db.resourceNames(), viewer: viewerPath, + viewers, + formats, + manifest: manifestPath, + manifestJson: manifestJsonPath, + manifestHtml: manifestHtmlPath, + manifestMarkdown: manifestMarkdownPath, schema: schemaPath, graphql: graphqlPath, links: { viewer: viewerPath, + viewers, + formats, + manifest: manifestPath, + manifestJson: manifestJsonPath, + manifestHtml: manifestHtmlPath, + manifestMarkdown: manifestMarkdownPath, schema: schemaPath, graphql: graphqlPath, resources: Object.fromEntries([...db.resources.values()].map((resource) => [resource.name, joinPaths(options.restBasePath ?? '', resource.routePath)])), @@ -276,87 +338,55 @@ function rootDiscovery(db, options = {}) { }; } -function joinPaths(basePath, routePath) { - if (!basePath) { - return routePath; - } - - const base = `/${String(basePath).replace(/^\/+/, '').replace(/\/+$/, '')}`; - const route = `/${String(routePath || '/').replace(/^\/+/, '')}`; - return `${base}${route === '/' ? '' : route}`; +function viewerLinks(config, viewerPath) { + const configuredLinks = Array.isArray(config.server?.viewerLinks) + ? config.server.viewerLinks + : []; + return [ + { + label: 'Data Viewer', + href: viewerPath, + source: 'built-in', + }, + ...configuredLinks.map(normalizeViewerLink).filter(Boolean), + ]; } -function requestPrefersHtml(request) { - const accept = headerValue(request, 'accept'); - if (!accept) { - return false; +function normalizeViewerLink(link) { + if (!link || typeof link !== 'object') { + return null; } - const preferences = parseAcceptHeader(accept); - const html = acceptedMediaScore(preferences, 'text/html'); - const json = acceptedMediaScore(preferences, 'application/json'); - return compareAcceptScores(html, json) > 0; -} - -function parseAcceptHeader(value) { - return String(value).split(',').map((entry, index) => { - const [mediaRange, ...parameters] = entry.trim().split(';'); - let quality = 1; - for (const parameter of parameters) { - const [name, rawValue] = parameter.trim().split('='); - if (name?.toLowerCase() === 'q') { - const parsed = Number(rawValue); - quality = Number.isFinite(parsed) ? Math.min(1, Math.max(0, parsed)) : 0; - } - } - - return { - index, - mediaRange: mediaRange.toLowerCase(), - quality, - }; - }).filter((preference) => preference.mediaRange.includes('/')); -} + const href = typeof link.href === 'string' ? link.href : link.url; + if (typeof href !== 'string' || href.trim() === '') { + return null; + } -function acceptedMediaScore(preferences, mediaType) { - const [wantedType, wantedSubtype] = mediaType.split('/'); - let best = { - quality: 0, - specificity: -1, - index: Number.MAX_SAFE_INTEGER, + return { + label: typeof link.label === 'string' && link.label.trim() ? link.label : 'Custom Viewer', + href, + source: 'custom', }; +} - for (const preference of preferences) { - const [type, subtype] = preference.mediaRange.split('/'); - if ((type !== '*' && type !== wantedType) || (subtype !== '*' && subtype !== wantedSubtype)) { - continue; - } - - const specificity = Number(type !== '*') + Number(subtype !== '*'); - const candidate = { - quality: preference.quality, - specificity, - index: preference.index, - }; - if (compareAcceptScores(candidate, best) > 0) { - best = candidate; - } +function joinPaths(basePath, routePath) { + if (!basePath) { + return routePath; } - return best; + const base = `/${String(basePath).replace(/^\/+/, '').replace(/\/+$/, '')}`; + const route = `/${String(routePath || '/').replace(/^\/+/, '')}`; + return `${base}${route === '/' ? '' : route}`; } -function compareAcceptScores(left, right) { - if (left.quality !== right.quality) { - return left.quality - right.quality; - } - if (left.specificity !== right.specificity) { - return left.specificity - right.specificity; - } - return right.index - left.index; +function requestPrefersHtml(config, request) { + return negotiateRestFormat(config, request, 'resource') === 'html'; } function renderRootDiscovery(discovery) { + const viewerLinksHtml = discovery.links.viewers.map((viewer) => ( + `
  • ${escapeHtml(viewer.label)} ${escapeHtml(viewer.href)}
  • ` + )).join(''); const resourceLinks = Object.entries(discovery.links.resources).map(([name, routePath]) => ( `
  • ${escapeHtml(name)} ${escapeHtml(routePath)}
  • ` )).join(''); @@ -388,7 +418,8 @@ function renderRootDiscovery(discovery) {

    Tools

      -
    • Data Viewer ${escapeHtml(discovery.viewer)}
    • + ${viewerLinksHtml} +
    • Viewer Manifest ${escapeHtml(discovery.manifest)}
    • Schema ${escapeHtml(discovery.schema)}
    • GraphQL ${escapeHtml(discovery.graphql)}
    @@ -437,7 +468,7 @@ async function importCsvFixture(db, request, options = {}) { dataPath: path.relative(db.config.cwd, outFile), statePath: path.relative(db.config.cwd, path.join(db.config.stateDir, 'state', `${resourceName}.json`)), routePath: resource?.routePath ?? `/${resourceName}`, - viewerPath: `${options.viewerPath ?? '/__jsondb'}?resource=${encodeURIComponent(resourceName)}`, + viewerPath: `${options.viewerPath ?? normalizeBasePath(db.config.server?.apiBase ?? '/__jsondb')}?resource=${encodeURIComponent(resourceName)}`, logs: project.logs, }; } @@ -522,14 +553,14 @@ async function executeRestBatchItem(db, item, options = {}) { `REST batch path must start with "/": ${requestPath}`, { status: 400, - hint: 'Use absolute local paths such as "/users", "/settings", or "/__jsondb/schema".', + hint: `Use absolute local paths such as "/users", "/settings", or "${options.schemaPath ?? `${normalizeBasePath(options.apiBase ?? db.config.server?.apiBase ?? '/__jsondb')}/schema`}".`, details: { path: requestPath }, }, ); } - const batchPath = options.batchPath ?? `${normalizeBasePath(options.apiBase ?? '/__jsondb')}/batch`; - if (requestPath === batchPath || requestPath === '/__jsondb/batch') { + const batchPath = batchPathForOptions(options, db); + if (requestPath === batchPath) { throw jsonDbError( 'REST_BATCH_NESTED_UNSUPPORTED', 'Nested REST batch requests are not supported.', @@ -556,6 +587,10 @@ async function executeRestBatchItem(db, item, options = {}) { }; } +function batchPathForOptions(options = {}, db = null) { + return options.batchPath ?? `${normalizeBasePath(options.apiBase ?? db?.config?.server?.apiBase ?? '/__jsondb')}/batch`; +} + async function tryRest(fn) { try { const body = await fn(); @@ -689,79 +724,76 @@ async function handleDocument(db, resource, request, response, format) { } async function sendFormattedResource(db, response, resource, data, format, request, url) { - const renderer = resolveFormatRenderer(db.config, format); - if (!renderer) { - const availableFormats = availableRestFormats(db.config); - sendJson(response, 404, { - error: { - code: 'REST_UNKNOWN_FORMAT', - message: `Unknown REST format "${format}".`, - hint: `Use one of: ${listChoices(availableFormats.map((item) => `.${item}`))}.`, - details: { - format, - availableFormats, - }, - }, - }); + const effectiveFormat = format ?? negotiateRestFormat(db.config, request, 'resource'); + const resolved = resolveRestFormat(db.config, effectiveFormat, 'resource'); + if (!resolved) { + sendUnknownFormat(response, effectiveFormat, db.config, 'resource'); return; } - const result = await renderer({ + const result = await resolved.renderer({ db, resource, resourceName: resource.name, data, - format: format ?? 'default', + format: resolved.key, request, url, + target: 'resource', }); - const normalized = normalizeFormatResult(result); + const normalized = normalizeFormatResult(result, resolved.contentType); + sendText(response, normalized.status, normalized.body, normalized.contentType); } -function resolveFormatRenderer(config, format) { - const formats = config.rest?.formats ?? {}; - const key = format ?? 'default'; - const configured = formats[key]; - - if (typeof configured === 'string') { - return resolveFormatRenderer(config, configured); +function manifestResponseFormat(url, request, routes, config) { + if (url.pathname === routes.manifestJsonPath) { + return 'json'; } - if (typeof configured === 'function') { - return configured; + if (url.pathname === routes.manifestHtmlPath) { + return 'html'; } - if (key === 'default') { - return resolveFormatRenderer({ ...config, rest: { ...config.rest, formats: { ...formats, default: 'json' } } }, null); + if (url.pathname === routes.manifestMarkdownPath) { + return 'md'; } - if (key === 'json') { - return ({ data }) => ({ - body: `${JSON.stringify(data, null, 2)}\n`, - contentType: 'application/json; charset=utf-8', - }); + if (url.pathname === routes.manifestPath) { + return negotiateRestFormat(config, request, 'manifest'); } - return null; + const parsed = splitFormatExtension(url.pathname); + return parsed.name === routes.manifestPath ? parsed.format : null; } -function availableRestFormats(config) { - return [...new Set(['json', ...Object.keys(config.rest?.formats ?? {}).filter((key) => key !== 'default')])].sort(); +function sendUnknownFormat(response, format, config, target) { + const availableFormats = availableRestFormats(config, target); + sendJson(response, 404, { + error: { + code: 'REST_UNKNOWN_FORMAT', + message: `Unknown REST format "${format}".`, + hint: `Use one of: ${listChoices(availableFormats.map((item) => `.${item}`))}.`, + details: { + format, + availableFormats, + }, + }, + }); } -function normalizeFormatResult(result) { +function normalizeFormatResult(result, defaultContentType = 'text/plain; charset=utf-8') { if (typeof result === 'string' || Buffer.isBuffer(result)) { return { status: 200, body: result, - contentType: 'text/plain; charset=utf-8', + contentType: defaultContentType, }; } return { status: result?.status ?? 200, body: result?.body ?? '', - contentType: result?.contentType ?? result?.headers?.['content-type'] ?? 'text/plain; charset=utf-8', + contentType: result?.contentType ?? result?.headers?.['content-type'] ?? defaultContentType, }; } diff --git a/src/rest/handler.test.js b/src/rest/handler.test.js index 6ac1c8b..f03a6a8 100644 --- a/src/rest/handler.test.js +++ b/src/rest/handler.test.js @@ -120,10 +120,30 @@ test('REST root returns JSON discovery links by default', async () => { assert.deepEqual(response.json(), { resources: ['users'], viewer: '/__jsondb', + viewers: [{ + label: 'Data Viewer', + href: '/__jsondb', + source: 'built-in', + }], + formats: builtInFormatMetadata('/__jsondb'), + manifest: '/__jsondb/manifest', + manifestJson: '/__jsondb/manifest.json', + manifestHtml: '/__jsondb/manifest.html', + manifestMarkdown: '/__jsondb/manifest.md', schema: '/__jsondb/schema', graphql: '/graphql', links: { viewer: '/__jsondb', + viewers: [{ + label: 'Data Viewer', + href: '/__jsondb', + source: 'built-in', + }], + formats: builtInFormatMetadata('/__jsondb'), + manifest: '/__jsondb/manifest', + manifestJson: '/__jsondb/manifest.json', + manifestHtml: '/__jsondb/manifest.html', + manifestMarkdown: '/__jsondb/manifest.md', schema: '/__jsondb/schema', graphql: '/graphql', resources: { @@ -133,6 +153,67 @@ test('REST root returns JSON discovery links by default', async () => { }); }); +test('REST root discovery links use configured server apiBase', async () => { + const cwd = await makeProject(); + await writeFixture(cwd, 'users.json', JSON.stringify([ + { + id: 'u_1', + name: 'Ada Lovelace', + }, + ])); + + const db = await openJsonFixtureDb({ + cwd, + server: { + apiBase: '/_jsondb', + }, + }); + const response = makeResponse(); + + await handleRestRequest( + db, + makeRequest('GET'), + response, + new URL('http://jsondb.local/'), + ); + + assert.equal(response.status, 200); + assert.deepEqual(response.json(), { + resources: ['users'], + viewer: '/_jsondb', + viewers: [{ + label: 'Data Viewer', + href: '/_jsondb', + source: 'built-in', + }], + formats: builtInFormatMetadata('/_jsondb'), + manifest: '/_jsondb/manifest', + manifestJson: '/_jsondb/manifest.json', + manifestHtml: '/_jsondb/manifest.html', + manifestMarkdown: '/_jsondb/manifest.md', + schema: '/_jsondb/schema', + graphql: '/graphql', + links: { + viewer: '/_jsondb', + viewers: [{ + label: 'Data Viewer', + href: '/_jsondb', + source: 'built-in', + }], + formats: builtInFormatMetadata('/_jsondb'), + manifest: '/_jsondb/manifest', + manifestJson: '/_jsondb/manifest.json', + manifestHtml: '/_jsondb/manifest.html', + manifestMarkdown: '/_jsondb/manifest.md', + schema: '/_jsondb/schema', + graphql: '/graphql', + resources: { + users: '/users', + }, + }, + }); +}); + test('REST root returns HTML discovery links for browser requests', async () => { const cwd = await makeProject(); await writeFixture(cwd, 'chartMappings.json', JSON.stringify([ @@ -159,6 +240,8 @@ test('REST root returns HTML discovery links for browser requests', async () => assert.match(response.body, /jsondb/); assert.match(response.body, /Data Viewer/); assert.match(response.body, /href="\/__jsondb"/); + assert.match(response.body, /Viewer Manifest/); + assert.match(response.body, /href="\/__jsondb\/manifest"/); assert.match(response.body, /Schema/); assert.match(response.body, /href="\/__jsondb\/schema"/); assert.match(response.body, /GraphQL/); @@ -167,6 +250,480 @@ test('REST root returns HTML discovery links for browser requests', async () => assert.match(response.body, /href="\/chart-mappings"/); }); +test('REST discovery and viewer manifest include configured custom viewer links', async () => { + const cwd = await makeProject(); + await writeFixture(cwd, 'users.json', JSON.stringify([ + { + id: 'u_1', + name: 'Ada Lovelace', + }, + ])); + + const db = await openJsonFixtureDb({ + cwd, + server: { + viewerLinks: [ + { label: 'Custom Viewer', href: 'http://127.0.0.1:5173/jsondb' }, + ], + }, + }); + const root = makeResponse(); + const manifest = makeResponse(); + + await handleRestRequest(db, makeRequest('GET'), root, new URL('http://jsondb.local/')); + await handleRestRequest(db, makeRequest('GET'), manifest, new URL('http://jsondb.local/__jsondb/manifest.json')); + + assert.deepEqual(root.json().links.viewers, [ + { + label: 'Data Viewer', + href: '/__jsondb', + source: 'built-in', + }, + { + label: 'Custom Viewer', + href: 'http://127.0.0.1:5173/jsondb', + source: 'custom', + }, + ]); + assert.deepEqual(manifest.json().api.viewers, root.json().links.viewers); +}); + +test('REST root discovery includes registered response format metadata', async () => { + const cwd = await makeProject(); + await writeFixture(cwd, 'users.json', JSON.stringify([])); + + const db = await openJsonFixtureDb({ + cwd, + rest: { + formats: { + yaml: { + mediaTypes: ['application/yaml', 'text/yaml'], + contentType: 'application/yaml; charset=utf-8', + render({ data }) { + return JSON.stringify(data); + }, + }, + }, + }, + }); + const response = makeResponse(); + + await handleRestRequest(db, makeRequest('GET'), response, new URL('http://jsondb.local/')); + + assert.deepEqual(response.json().formats.yaml, { + extension: '.yaml', + mediaTypes: ['application/yaml', 'text/yaml'], + contentType: 'application/yaml; charset=utf-8', + manifestPath: '/__jsondb/manifest.yaml', + }); +}); + +test('REST explicit .json routes keep raw JSON even for browser requests', async () => { + const cwd = await makeProject(); + await writeFixture(cwd, 'users.json', JSON.stringify([ + { + id: 'u_1', + name: 'Ada Lovelace', + }, + ])); + + const db = await openJsonFixtureDb({ cwd }); + const manifest = makeResponse(); + const users = makeResponse(); + + await handleRestRequest( + db, + makeRequest('GET', undefined, { + accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', + }), + manifest, + new URL('http://jsondb.local/__jsondb/manifest.json'), + ); + await handleRestRequest( + db, + makeRequest('GET', undefined, { + accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', + }), + users, + new URL('http://jsondb.local/users.json'), + ); + + assert.equal(manifest.status, 200); + assert.match(manifest.headers['content-type'], /application\/json/); + assert.equal(manifest.json().kind, 'jsondb.viewerManifest'); + assert.equal(users.status, 200); + assert.match(users.headers['content-type'], /application\/json/); + assert.deepEqual(users.json(), [{ id: 'u_1', name: 'Ada Lovelace' }]); +}); + +test('REST explicit .html routes render the formatted JSON viewer', async () => { + const cwd = await makeProject(); + await writeFixture(cwd, 'users.json', JSON.stringify([ + { + id: 'u_1', + name: 'Ada Lovelace', + }, + ])); + + const db = await openJsonFixtureDb({ cwd }); + const manifest = makeResponse(); + const users = makeResponse(); + + await handleRestRequest( + db, + makeRequest('GET'), + manifest, + new URL('http://jsondb.local/__jsondb/manifest.html'), + ); + await handleRestRequest( + db, + makeRequest('GET'), + users, + new URL('http://jsondb.local/users.html'), + ); + + assert.equal(manifest.status, 200); + assert.match(manifest.headers['content-type'], /text\/html/); + assert.match(manifest.body, /cdn\.tailwindcss\.com/); + assert.match(manifest.body, //); + assert.match(manifest.body, /data-theme-mode="dark"/); + assert.match(manifest.body, /data-theme-choice="system"/); + assert.match(manifest.body, /data-format-choice="pretty" aria-pressed="true"/); + assert.match(manifest.body, /data-format-choice="raw"/); + assert.match(manifest.body, /id="copy-json"/); + assert.match(manifest.body, /"kind": "jsondb\.viewerManifest"/); + assert.match(manifest.body, /"api": \{/); + assert.equal(users.status, 200); + assert.match(users.headers['content-type'], /text\/html/); + assert.match(users.body, /"id": "u_1"/); + assert.match(users.body, /"name": "Ada Lovelace"/); +}); + +test('REST explicit .md routes render AI-friendly markdown', async () => { + const cwd = await makeProject(); + await writeFixture(cwd, 'settings.json', JSON.stringify({ + theme: 'dark', + locale: 'en-US', + })); + + const db = await openJsonFixtureDb({ cwd }); + const manifest = makeResponse(); + const settings = makeResponse(); + + await handleRestRequest( + db, + makeRequest('GET'), + manifest, + new URL('http://jsondb.local/__jsondb/manifest.md'), + ); + await handleRestRequest( + db, + makeRequest('GET'), + settings, + new URL('http://jsondb.local/settings.md'), + ); + + assert.equal(manifest.status, 200); + assert.match(manifest.headers['content-type'], /text\/markdown/); + assert.match(manifest.body, /^# jsondb viewer manifest/m); + assert.match(manifest.body, /```json/); + assert.match(manifest.body, /"kind": "jsondb\.viewerManifest"/); + assert.equal(settings.status, 200); + assert.match(settings.headers['content-type'], /text\/markdown/); + assert.match(settings.body, /^# settings/m); + assert.match(settings.body, /- Kind: `document`/); + assert.match(settings.body, /"theme": "dark"/); +}); + +test('REST extensionless manifest and resource routes negotiate HTML or JSON', async () => { + const cwd = await makeProject(); + await writeFixture(cwd, 'users.json', JSON.stringify([ + { + id: 'u_1', + name: 'Ada Lovelace', + }, + ])); + + const db = await openJsonFixtureDb({ cwd }); + const manifestHtml = makeResponse(); + const usersHtml = makeResponse(); + const manifestJson = makeResponse(); + const usersJson = makeResponse(); + + await handleRestRequest( + db, + makeRequest('GET', undefined, { + accept: 'text/html,application/xhtml+xml,application/json;q=0.5,*/*;q=0.1', + }), + manifestHtml, + new URL('http://jsondb.local/__jsondb/manifest'), + ); + await handleRestRequest( + db, + makeRequest('GET', undefined, { + accept: 'text/html,application/xhtml+xml,application/json;q=0.5,*/*;q=0.1', + }), + usersHtml, + new URL('http://jsondb.local/users'), + ); + await handleRestRequest( + db, + makeRequest('GET', undefined, { + accept: 'application/json', + }), + manifestJson, + new URL('http://jsondb.local/__jsondb/manifest'), + ); + await handleRestRequest( + db, + makeRequest('GET', undefined, { + accept: 'application/json', + }), + usersJson, + new URL('http://jsondb.local/users'), + ); + + assert.match(manifestHtml.headers['content-type'], /text\/html/); + assert.match(manifestHtml.body, /jsondb viewer manifest/); + assert.match(usersHtml.headers['content-type'], /text\/html/); + assert.match(usersHtml.body, /"name": "Ada Lovelace"/); + assert.match(manifestJson.headers['content-type'], /application\/json/); + assert.equal(manifestJson.json().kind, 'jsondb.viewerManifest'); + assert.match(usersJson.headers['content-type'], /application\/json/); + assert.deepEqual(usersJson.json(), [{ id: 'u_1', name: 'Ada Lovelace' }]); +}); + +test('REST extensionless manifest and resource routes negotiate markdown', async () => { + const cwd = await makeProject(); + await writeFixture(cwd, 'users.json', JSON.stringify([ + { + id: 'u_1', + name: 'Ada Lovelace', + }, + ])); + + const db = await openJsonFixtureDb({ cwd }); + const manifest = makeResponse(); + const users = makeResponse(); + + await handleRestRequest( + db, + makeRequest('GET', undefined, { + accept: 'text/markdown,application/json;q=0.5,*/*;q=0.1', + }), + manifest, + new URL('http://jsondb.local/__jsondb/manifest'), + ); + await handleRestRequest( + db, + makeRequest('GET', undefined, { + accept: 'text/markdown,application/json;q=0.5,*/*;q=0.1', + }), + users, + new URL('http://jsondb.local/users'), + ); + + assert.match(manifest.headers['content-type'], /text\/markdown/); + assert.match(manifest.body, /^# jsondb viewer manifest/m); + assert.match(users.headers['content-type'], /text\/markdown/); + assert.match(users.body, /^# users/m); + assert.match(users.body, /- Kind: `collection`/); +}); + +test('REST format registry renders object formats for resource and manifest routes', async () => { + const cwd = await makeProject(); + await writeFixture(cwd, 'users.json', JSON.stringify([ + { + id: 'u_1', + name: 'Ada Lovelace', + }, + ])); + + const db = await openJsonFixtureDb({ + cwd, + rest: { + formats: { + yaml: { + mediaTypes: ['application/yaml', 'text/yaml'], + contentType: 'application/yaml; charset=utf-8', + render({ data, format }) { + return `format: ${format}\njson: ${JSON.stringify(data)}\n`; + }, + renderManifest({ data, format }) { + return `format: ${format}\nkind: ${data.kind}\n`; + }, + }, + }, + }, + }); + const users = makeResponse(); + const manifest = makeResponse(); + + await handleRestRequest(db, makeRequest('GET'), users, new URL('http://jsondb.local/users.yaml')); + await handleRestRequest(db, makeRequest('GET'), manifest, new URL('http://jsondb.local/__jsondb/manifest.yaml')); + + assert.equal(users.status, 200); + assert.match(users.headers['content-type'], /application\/yaml/); + assert.match(users.body, /format: yaml/); + assert.match(users.body, /Ada Lovelace/); + assert.equal(manifest.status, 200); + assert.match(manifest.headers['content-type'], /application\/yaml/); + assert.match(manifest.body, /format: yaml/); + assert.match(manifest.body, /kind: jsondb\.viewerManifest/); +}); + +test('REST format registry negotiates custom media types and falls back to default', async () => { + const cwd = await makeProject(); + await writeFixture(cwd, 'users.json', JSON.stringify([ + { + id: 'u_1', + name: 'Ada Lovelace', + }, + ])); + + const db = await openJsonFixtureDb({ + cwd, + rest: { + formats: { + yaml: { + mediaTypes: ['application/yaml', 'text/yaml'], + contentType: 'application/yaml; charset=utf-8', + render({ data }) { + return `yaml: ${JSON.stringify(data)}\n`; + }, + }, + }, + }, + }); + const yamlUsers = makeResponse(); + const yamlManifest = makeResponse(); + const fallbackUsers = makeResponse(); + const fallbackManifest = makeResponse(); + + await handleRestRequest( + db, + makeRequest('GET', undefined, { + accept: 'application/json;q=0.4,application/yaml;q=0.9,text/html;q=0.2', + }), + yamlUsers, + new URL('http://jsondb.local/users'), + ); + await handleRestRequest( + db, + makeRequest('GET', undefined, { + accept: 'application/json;q=0.4,application/yaml;q=0.9,text/html;q=0.2', + }), + yamlManifest, + new URL('http://jsondb.local/__jsondb/manifest'), + ); + await handleRestRequest( + db, + makeRequest('GET', undefined, { + accept: 'application/xml', + }), + fallbackUsers, + new URL('http://jsondb.local/users'), + ); + await handleRestRequest( + db, + makeRequest('GET', undefined, { + accept: 'application/xml', + }), + fallbackManifest, + new URL('http://jsondb.local/__jsondb/manifest'), + ); + + assert.match(yamlUsers.headers['content-type'], /application\/yaml/); + assert.match(yamlUsers.body, /Ada Lovelace/); + assert.match(yamlManifest.headers['content-type'], /application\/yaml/); + assert.match(yamlManifest.body, /jsondb\.viewerManifest/); + assert.match(fallbackUsers.headers['content-type'], /application\/json/); + assert.deepEqual(fallbackUsers.json(), [{ id: 'u_1', name: 'Ada Lovelace' }]); + assert.match(fallbackManifest.headers['content-type'], /application\/json/); + assert.equal(fallbackManifest.json().kind, 'jsondb.viewerManifest'); +}); + +test('REST format registry lets object entries override built-in JSON and markdown', async () => { + const cwd = await makeProject(); + await writeFixture(cwd, 'settings.json', JSON.stringify({ + theme: 'dark', + })); + + const db = await openJsonFixtureDb({ + cwd, + rest: { + formats: { + json: { + mediaTypes: ['application/vnd.custom+json', 'application/json'], + contentType: 'application/vnd.custom+json; charset=utf-8', + render({ data }) { + return JSON.stringify({ wrapped: data }); + }, + }, + md: { + mediaTypes: ['text/markdown'], + renderResource({ resourceName, data }) { + return { + body: `# custom ${resourceName}\n${JSON.stringify(data)}\n`, + contentType: 'text/markdown; charset=utf-8', + }; + }, + renderManifest({ data }) { + return { + body: `# custom manifest\n${data.kind}\n`, + contentType: 'text/markdown; charset=utf-8', + }; + }, + }, + }, + }, + }); + const settingsJson = makeResponse(); + const manifestJson = makeResponse(); + const settingsMarkdown = makeResponse(); + const manifestMarkdown = makeResponse(); + + await handleRestRequest(db, makeRequest('GET'), settingsJson, new URL('http://jsondb.local/settings.json')); + await handleRestRequest(db, makeRequest('GET'), manifestJson, new URL('http://jsondb.local/__jsondb/manifest.json')); + await handleRestRequest(db, makeRequest('GET'), settingsMarkdown, new URL('http://jsondb.local/settings.md')); + await handleRestRequest(db, makeRequest('GET'), manifestMarkdown, new URL('http://jsondb.local/__jsondb/manifest.md')); + + assert.match(settingsJson.headers['content-type'], /application\/vnd\.custom\+json/); + assert.deepEqual(JSON.parse(settingsJson.body), { wrapped: { theme: 'dark' } }); + assert.match(manifestJson.headers['content-type'], /application\/vnd\.custom\+json/); + assert.equal(JSON.parse(manifestJson.body).wrapped.kind, 'jsondb.viewerManifest'); + assert.equal(settingsMarkdown.body, '# custom settings\n{"theme":"dark"}\n'); + assert.equal(manifestMarkdown.body, '# custom manifest\njsondb.viewerManifest\n'); +}); + +test('REST unknown format errors list registered custom formats', async () => { + const cwd = await makeProject(); + await writeFixture(cwd, 'users.json', JSON.stringify([])); + + const db = await openJsonFixtureDb({ + cwd, + rest: { + formats: { + yaml: { + mediaTypes: ['application/yaml'], + render({ data }) { + return JSON.stringify(data); + }, + }, + }, + }, + }); + const response = makeResponse(); + + await handleRestRequest(db, makeRequest('GET'), response, new URL('http://jsondb.local/users.xml')); + + assert.equal(response.status, 404); + assert.equal(response.json().error.code, 'REST_UNKNOWN_FORMAT'); + assert.deepEqual(response.json().error.details.availableFormats, ['html', 'json', 'md', 'yaml']); + assert.match(response.json().error.hint, /\.yaml/); +}); + test('REST schema endpoint exposes route paths for the viewer', async () => { const cwd = await makeProject(); await writeFixture(cwd, 'auditEvents.json', JSON.stringify([ @@ -919,6 +1476,28 @@ test('REST batch errors include code hint and item index', async () => { assert.match(response.json()[0].body.error.hint, /absolute local paths/); }); +test('REST batch invalid body hint uses custom apiBase batch path', async () => { + const cwd = await makeProject(); + await writeFixture(cwd, 'users.json', JSON.stringify([])); + + const db = await openJsonFixtureDb({ cwd }); + const response = makeResponse(); + + await handleRestRequest( + db, + makeRequest('POST', { + requests: 'nope', + }), + response, + new URL('http://jsondb.local/_jsondb/batch'), + { apiBase: '/_jsondb' }, + ); + + assert.equal(response.status, 400); + assert.equal(response.json().error.code, 'REST_BATCH_INVALID_BODY'); + assert.match(response.json().error.hint, /POST \/_jsondb\/batch/); +}); + test('REST batch rejects nested requests to a custom apiBase batch path', async () => { const cwd = await makeProject(); await writeFixture(cwd, 'users.json', JSON.stringify([])); @@ -947,6 +1526,33 @@ test('REST batch rejects nested requests to a custom apiBase batch path', async assert.match(response.json()[0].body.error.hint, /Flatten the batch array/); }); +test('REST batch nested request detection uses the effective batch path only', async () => { + const cwd = await makeProject(); + await writeFixture(cwd, 'users.json', JSON.stringify([])); + + const db = await openJsonFixtureDb({ cwd }); + const response = makeResponse(); + + await handleRestRequest( + db, + makeRequest('POST', [ + { + method: 'POST', + path: '/__jsondb/batch', + body: [], + }, + ]), + response, + new URL('http://jsondb.local/_jsondb/batch'), + { apiBase: '/_jsondb' }, + ); + + assert.equal(response.status, 200); + assert.equal(response.json()[0].index, 0); + assert.equal(response.json()[0].status, 404); + assert.equal(response.json()[0].body.error.code, 'REST_UNKNOWN_RESOURCE'); +}); + test('REST handler returns 413 for oversized JSON bodies', async () => { const cwd = await makeProject(); await writeFixture(cwd, 'users.json', JSON.stringify([])); @@ -1015,3 +1621,26 @@ function makeResponse() { }, }; } + +function builtInFormatMetadata(apiBase) { + return { + html: { + extension: '.html', + mediaTypes: ['text/html'], + contentType: 'text/html; charset=utf-8', + manifestPath: `${apiBase}/manifest.html`, + }, + json: { + extension: '.json', + mediaTypes: ['application/json'], + contentType: 'application/json; charset=utf-8', + manifestPath: `${apiBase}/manifest.json`, + }, + md: { + extension: '.md', + mediaTypes: ['text/markdown'], + contentType: 'text/markdown; charset=utf-8', + manifestPath: `${apiBase}/manifest.md`, + }, + }; +} diff --git a/src/server.js b/src/server.js index f772579..da22ee7 100644 --- a/src/server.js +++ b/src/server.js @@ -85,6 +85,10 @@ async function handleRequest(db, request, response, events, routes) { rootRoutes: false, restBasePath: `${forkApiBase(routes, forkName)}/rest`, graphqlPath: `${forkApiBase(routes, forkName)}/graphql`, + manifestPath: `${forkApiBase(routes, forkName)}/manifest`, + manifestJsonPath: `${forkApiBase(routes, forkName)}/manifest.json`, + manifestHtmlPath: `${forkApiBase(routes, forkName)}/manifest.html`, + manifestMarkdownPath: `${forkApiBase(routes, forkName)}/manifest.md`, }); return handleRequest(forkDb, request, response, events, forkRoutes); } catch (error) { @@ -290,7 +294,7 @@ function writeViewerEvent(response, payload) { } function resolveRequestRoutes(config, options) { - const apiBase = normalizeBasePath(options.apiBase ?? '/__jsondb'); + const apiBase = normalizeBasePath(options.apiBase ?? config.server?.apiBase ?? '/__jsondb'); const restBasePath = options.restBasePath === undefined ? null : normalizeBasePath(options.restBasePath); @@ -302,6 +306,10 @@ function resolveRequestRoutes(config, options) { restBasePath, graphqlPath, viewerPath: apiBase, + manifestPath: `${apiBase}/manifest`, + manifestJsonPath: `${apiBase}/manifest.json`, + manifestHtmlPath: `${apiBase}/manifest.html`, + manifestMarkdownPath: `${apiBase}/manifest.md`, schemaPath: `${apiBase}/schema`, batchPath: `${apiBase}/batch`, importPath: `${apiBase}/import`, @@ -333,13 +341,26 @@ function restUrlForRequest(url, routes) { return url; } - if ([routes.viewerPath, routes.schemaPath, routes.batchPath, routes.importPath].includes(url.pathname)) { + if ([routes.viewerPath, routes.schemaPath, routes.batchPath, routes.importPath].includes(url.pathname) || isManifestRoutePath(url.pathname, routes)) { return url; } return null; } +function isManifestRoutePath(pathname, routes) { + if ([routes.manifestPath, routes.manifestJsonPath, routes.manifestHtmlPath, routes.manifestMarkdownPath].includes(pathname)) { + return true; + } + + if (!pathname.startsWith(`${routes.manifestPath}.`)) { + return false; + } + + const extension = pathname.slice(routes.manifestPath.length + 1); + return /^[A-Za-z][A-Za-z0-9_-]*$/.test(extension); +} + function joinPaths(basePath, routePath) { const base = `/${String(basePath ?? '').replace(/^\/+/, '').replace(/\/+$/, '')}`; const route = `/${String(routePath ?? '').replace(/^\/+/, '')}`; diff --git a/src/server.test.js b/src/server.test.js index e9c417a..3ad9960 100644 --- a/src/server.test.js +++ b/src/server.test.js @@ -171,7 +171,21 @@ test('request handler supports scoped Vite routes without root REST routes', asy }, ])); - const db = await openJsonFixtureDb({ cwd, allowSourceErrors: true }); + const db = await openJsonFixtureDb({ + cwd, + allowSourceErrors: true, + rest: { + formats: { + yaml: { + mediaTypes: ['application/yaml'], + contentType: 'application/yaml; charset=utf-8', + render({ data }) { + return `yaml: ${JSON.stringify(data)}\n`; + }, + }, + }, + }, + }); const handler = createJsonDbRequestHandler(db, { apiBase: '/__jsondb', rootRoutes: false, @@ -181,6 +195,9 @@ test('request handler supports scoped Vite routes without root REST routes', asy const users = makeResponse(); const schema = makeResponse(); + const manifest = makeResponse(); + const manifestMarkdown = makeResponse(); + const manifestYaml = makeResponse(); const batch = makeResponse(); const graphql = makeResponse(); const rootUsers = makeResponse(); @@ -188,6 +205,9 @@ test('request handler supports scoped Vite routes without root REST routes', asy assert.equal(await handler(makeRequest('GET', '/__jsondb/rest/users'), users), true); assert.equal(await handler(makeRequest('GET', '/__jsondb/schema'), schema), true); + assert.equal(await handler(makeRequest('GET', '/__jsondb/manifest'), manifest), true); + assert.equal(await handler(makeRequest('GET', '/__jsondb/manifest.md'), manifestMarkdown), true); + assert.equal(await handler(makeRequest('GET', '/__jsondb/manifest.yaml'), manifestYaml), true); assert.equal(await handler(makeRequest('POST', '/__jsondb/batch', [ { method: 'GET', path: '/users' }, ]), batch), true); @@ -202,6 +222,17 @@ test('request handler supports scoped Vite routes without root REST routes', asy assert.deepEqual(users.json(), [{ id: 'u_1', name: 'Ada' }]); assert.equal(schema.status, 200); assert.equal(schema.json().resources.users.routePath, '/users'); + assert.equal(manifest.status, 200); + assert.equal(manifest.json().api.manifest, '/__jsondb/manifest'); + assert.equal(manifest.json().api.manifestJson, '/__jsondb/manifest.json'); + assert.equal(manifest.json().api.manifestMarkdown, '/__jsondb/manifest.md'); + assert.equal(manifest.json().api.resources.users.list, '/__jsondb/rest/users'); + assert.equal(manifestMarkdown.status, 200); + assert.match(manifestMarkdown.headers['content-type'], /text\/markdown/); + assert.match(manifestMarkdown.body, /^# jsondb viewer manifest/m); + assert.equal(manifestYaml.status, 200); + assert.match(manifestYaml.headers['content-type'], /application\/yaml/); + assert.match(manifestYaml.body, /jsondb\.viewerManifest/); assert.equal(batch.status, 200); assert.equal(batch.json()[0].body[0].id, 'u_1'); assert.equal(graphql.status, 200); @@ -235,6 +266,115 @@ test('request handler preserves standalone root REST and GraphQL routes', async assert.deepEqual(graphql.json().data.users, [{ id: 'u_1' }]); }); +test('request handler derives standalone dev-tool routes from configured server apiBase', async () => { + const cwd = await makeProject(); + await writeFixture(cwd, 'users.json', JSON.stringify([ + { + id: 'u_main', + name: 'Main Ada', + }, + ])); + await mkdir(path.join(cwd, 'db.forks/legacy-demo'), { recursive: true }); + await writeFile(path.join(cwd, 'db.forks/legacy-demo/users.json'), `${JSON.stringify([ + { + id: 'u_legacy', + fullName: 'Legacy Ada', + }, + ])}\n`, 'utf8'); + await writeConfig(cwd, `export default { + server: { + apiBase: '/_jsondb', + }, + rest: { + formats: { + yaml: { + mediaTypes: ['application/yaml'], + contentType: 'application/yaml; charset=utf-8', + render({ data }) { + return 'yaml: ' + JSON.stringify(data) + '\\n'; + }, + }, + }, + }, + forks: ['legacy-demo'], + };`); + + const db = await openJsonFixtureDb({ cwd, allowSourceErrors: true }); + const handler = createJsonDbRequestHandler(db); + const viewer = makeResponse(); + const schema = makeResponse(); + const manifest = makeResponse(); + const batch = makeResponse(); + const imported = makeResponse(); + const events = makeResponse(); + const log = makeResponse(); + const forkUsers = makeResponse(); + const forkBatch = makeResponse(); + const forkSchema = makeResponse(); + const forkManifest = makeResponse(); + const forkManifestYaml = makeResponse(); + const forkGraphql = makeResponse(); + const rootUsers = makeResponse(); + const rootGraphql = makeResponse(); + + assert.equal(await handler(makeRequest('GET', '/_jsondb'), viewer), true); + assert.equal(await handler(makeRequest('GET', '/_jsondb/schema'), schema), true); + assert.equal(await handler(makeRequest('GET', '/_jsondb/manifest'), manifest), true); + assert.equal(await handler(makeRequest('POST', '/_jsondb/batch', [ + { method: 'GET', path: '/users' }, + ]), batch), true); + assert.equal(await handler(makeRawRequest('POST', '/_jsondb/import', 'id,name\nu_2,Grace\n', { + 'x-jsondb-file-name': 'Imported Users.csv', + }), imported), true); + assert.equal(await handler(makeRequest('GET', '/_jsondb/events'), events), true); + assert.equal(await handler(makeRequest('GET', '/_jsondb/log'), log), true); + assert.equal(await handler(makeRequest('GET', '/_jsondb/forks/legacy-demo/rest/users'), forkUsers), true); + assert.equal(await handler(makeRequest('POST', '/_jsondb/forks/legacy-demo/batch', [ + { method: 'GET', path: '/users' }, + ]), forkBatch), true); + assert.equal(await handler(makeRequest('GET', '/_jsondb/forks/legacy-demo/schema'), forkSchema), true); + assert.equal(await handler(makeRequest('GET', '/_jsondb/forks/legacy-demo/manifest'), forkManifest), true); + assert.equal(await handler(makeRequest('GET', '/_jsondb/forks/legacy-demo/manifest.yaml'), forkManifestYaml), true); + assert.equal(await handler(makeRequest('POST', '/_jsondb/forks/legacy-demo/graphql', { + query: '{ users { id fullName } }', + }), forkGraphql), true); + assert.equal(await handler(makeRequest('GET', '/users'), rootUsers), true); + assert.equal(await handler(makeRequest('POST', '/graphql', { + query: '{ users { id } }', + }), rootGraphql), true); + + assert.equal(viewer.status, 200); + assert.match(viewer.body, /jsondb viewer/); + assert.equal(schema.status, 200); + assert.equal(schema.json().resources.users.routePath, '/users'); + assert.equal(manifest.status, 200); + assert.equal(manifest.json().api.manifest, '/_jsondb/manifest'); + assert.equal(manifest.json().api.manifestJson, '/_jsondb/manifest.json'); + assert.equal(manifest.json().api.manifestMarkdown, '/_jsondb/manifest.md'); + assert.equal(manifest.json().api.resources.users.list, '/users'); + assert.equal(batch.status, 200); + assert.equal(batch.json()[0].body[0].id, 'u_main'); + assert.equal(imported.status, 201); + assert.equal(imported.json().viewerPath, '/_jsondb?resource=importedUsers'); + assert.equal(events.status, 200); + assert.match(events.body, /event: jsondb/); + assert.equal(log.status, 200); + assert.match(log.headers['content-type'], /text\/event-stream/); + assert.deepEqual(forkUsers.json(), [{ id: 'u_legacy', fullName: 'Legacy Ada' }]); + assert.equal(forkBatch.json()[0].body[0].id, 'u_legacy'); + assert.equal(forkSchema.json().resources.users.fields.fullName.type, 'string'); + assert.equal(forkManifest.json().api.manifest, '/_jsondb/forks/legacy-demo/manifest'); + assert.equal(forkManifest.json().api.manifestJson, '/_jsondb/forks/legacy-demo/manifest.json'); + assert.equal(forkManifest.json().api.manifestMarkdown, '/_jsondb/forks/legacy-demo/manifest.md'); + assert.equal(forkManifest.json().api.resources.users.list, '/_jsondb/forks/legacy-demo/rest/users'); + assert.equal(forkManifestYaml.status, 200); + assert.match(forkManifestYaml.headers['content-type'], /application\/yaml/); + assert.match(forkManifestYaml.body, /jsondb\.viewerManifest/); + assert.deepEqual(forkGraphql.json().data.users, [{ id: 'u_legacy', fullName: 'Legacy Ada' }]); + assert.deepEqual(rootUsers.json(), [{ id: 'u_main', name: 'Main Ada' }]); + assert.deepEqual(rootGraphql.json().data.users, [{ id: 'u_main' }]); +}); + test('request handler routes configured fork REST, batch, schema, and GraphQL requests', async () => { const cwd = await makeProject(); await writeFixture(cwd, 'users.json', JSON.stringify([ @@ -260,6 +400,7 @@ test('request handler routes configured fork REST, batch, schema, and GraphQL re const forkUsers = makeResponse(); const forkBatch = makeResponse(); const forkSchema = makeResponse(); + const forkManifest = makeResponse(); const forkGraphql = makeResponse(); assert.equal(await handler(makeRequest('GET', '/users'), mainUsers), true); @@ -268,6 +409,7 @@ test('request handler routes configured fork REST, batch, schema, and GraphQL re { method: 'GET', path: '/users' }, ]), forkBatch), true); assert.equal(await handler(makeRequest('GET', '/__jsondb/forks/legacy-demo/schema'), forkSchema), true); + assert.equal(await handler(makeRequest('GET', '/__jsondb/forks/legacy-demo/manifest'), forkManifest), true); assert.equal(await handler(makeRequest('POST', '/__jsondb/forks/legacy-demo/graphql', { query: '{ users { id fullName } }', }), forkGraphql), true); @@ -276,6 +418,10 @@ test('request handler routes configured fork REST, batch, schema, and GraphQL re assert.deepEqual(forkUsers.json(), [{ id: 'u_legacy', fullName: 'Legacy Ada' }]); assert.equal(forkBatch.json()[0].body[0].id, 'u_legacy'); assert.equal(forkSchema.json().resources.users.fields.fullName.type, 'string'); + assert.equal(forkManifest.json().api.manifest, '/__jsondb/forks/legacy-demo/manifest'); + assert.equal(forkManifest.json().api.manifestJson, '/__jsondb/forks/legacy-demo/manifest.json'); + assert.equal(forkManifest.json().api.manifestMarkdown, '/__jsondb/forks/legacy-demo/manifest.md'); + assert.equal(forkManifest.json().api.resources.users.list, '/__jsondb/forks/legacy-demo/rest/users'); assert.deepEqual(forkGraphql.json().data.users, [{ id: 'u_legacy', fullName: 'Legacy Ada' }]); }); @@ -315,10 +461,10 @@ test('request handler streams live runtime log events', async () => { assert.match(response.body, /"op":"create"/); }); -function makeRequest(method, path, body) { +function makeRequest(method, requestPath, body) { return { method, - url: path, + url: requestPath, headers: {}, async *[Symbol.asyncIterator]() { if (body !== undefined) { @@ -329,6 +475,18 @@ function makeRequest(method, path, body) { }; } +function makeRawRequest(method, requestPath, body, headers = {}) { + return { + method, + url: requestPath, + headers, + async *[Symbol.asyncIterator]() { + yield Buffer.from(body); + }, + on() {}, + }; +} + function makeResponse() { return { status: null, diff --git a/src/shared/mock.js b/src/shared/mock.js index 0710d0d..de8fb12 100644 --- a/src/shared/mock.js +++ b/src/shared/mock.js @@ -1,6 +1,6 @@ export async function runMockBehavior(config, url = null) { const mock = config.mock ?? config.chaos; - if (!mock || shouldSkipMock(url)) { + if (!mock || shouldSkipMock(config, url)) { return null; } @@ -85,8 +85,13 @@ function clampRate(value) { return Math.min(1, Math.max(0, Number(value))); } -function shouldSkipMock(url) { - return url?.pathname === '/__jsondb'; +function shouldSkipMock(config, url) { + return url?.pathname === normalizeBasePath(config.server?.apiBase ?? '/__jsondb'); +} + +function normalizeBasePath(value) { + const path = `/${String(value ?? '').replace(/^\/+/, '').replace(/\/+$/, '')}`; + return path === '/' ? '' : path; } function sleep(ms) { diff --git a/src/viewer-manifest.js b/src/viewer-manifest.js new file mode 100644 index 0000000..bef2b2d --- /dev/null +++ b/src/viewer-manifest.js @@ -0,0 +1 @@ +export { generateViewerManifest, renderViewerManifest } from './features/viewer/manifest.js'; diff --git a/src/vite.d.ts b/src/vite.d.ts index 294928e..6f1ad66 100644 --- a/src/vite.d.ts +++ b/src/vite.d.ts @@ -5,7 +5,7 @@ export type JsonDbVirtualClient = JsonDbClient & { fork(name: string): JsonDbClient; }; -export type JsonDbVitePluginOptions = Pick & { +export type JsonDbVitePluginOptions = Pick & { /** Scoped base for jsondb dev tools. Defaults to "/__jsondb". */ apiBase?: string; /** Serve root REST routes such as "/users" during Vite dev. Defaults to false. */ diff --git a/src/vite.test.js b/src/vite.test.js index 4377d7d..dcb41cf 100644 --- a/src/vite.test.js +++ b/src/vite.test.js @@ -34,6 +34,37 @@ test('jsondb Vite virtual client creates fork clients under the configured apiBa assert.match(loaded, /graphqlPath: `\$\{forkBase\}\/graphql`/); }); +test('jsondb Vite plugin falls back to configured server apiBase', async () => { + const plugin = jsondbPlugin({ + server: { + apiBase: '/_jsondb', + }, + }); + + const loaded = await plugin.load('\0virtual:jsondb/client'); + + assert.match(loaded, /restBasePath: "\/_jsondb\/rest"/); + assert.match(loaded, /graphqlPath: "\/_jsondb\/graphql"/); + assert.match(loaded, /restBatchPath: "\/_jsondb\/batch"/); + assert.match(loaded, /const forkBase = `\/_jsondb\/forks\/\$\{encodeURIComponent\(forkName\)\}`;/); +}); + +test('jsondb Vite plugin apiBase option wins over configured server apiBase', async () => { + const plugin = jsondbPlugin({ + apiBase: '/plugin-jsondb', + server: { + apiBase: '/_jsondb', + }, + }); + + const loaded = await plugin.load('\0virtual:jsondb/client'); + + assert.match(loaded, /restBasePath: "\/plugin-jsondb\/rest"/); + assert.match(loaded, /graphqlPath: "\/plugin-jsondb\/graphql"/); + assert.match(loaded, /restBatchPath: "\/plugin-jsondb\/batch"/); + assert.doesNotMatch(loaded, /\/_jsondb/); +}); + test('jsondb Vite plugin can render a custom client import for the virtual module', async () => { const plugin = jsondbPlugin({ clientImport: '@local/jsondb/client', diff --git a/src/web/json-viewer.js b/src/web/json-viewer.js new file mode 100644 index 0000000..ad03bca --- /dev/null +++ b/src/web/json-viewer.js @@ -0,0 +1,179 @@ +export function renderJsonViewer(value, options = {}) { + const title = options.title ?? 'jsondb JSON'; + const json = normalizeJsonText(value); + const formatted = formatJsonText(json); + const compact = compactJsonText(json); + + return ` + + + + + ${escapeHtml(title)} + + + +
    +

    ${escapeHtml(title)}

    +
    +
    + + + +
    +
    + + +
    + + +
    +
    +
    +
    ${escapeHtml(formatted)}
    +
    + + +`; +} + +function normalizeJsonText(value) { + return typeof value === 'string' ? value : JSON.stringify(value); +} + +function formatJsonText(value) { + try { + return JSON.stringify(JSON.parse(value), null, 2); + } catch { + return String(value); + } +} + +function compactJsonText(value) { + try { + return JSON.stringify(JSON.parse(value)); + } catch { + return String(value); + } +} + +function escapeHtml(value) { + return String(value) + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); +} diff --git a/src/web/viewer.js b/src/web/viewer.js index 70cb21f..8062ebc 100644 --- a/src/web/viewer.js +++ b/src/web/viewer.js @@ -1,6 +1,7 @@ export function renderJsonDbViewer(options = {}) { const graphqlPath = options.graphqlPath ?? '/graphql'; const schemaPath = options.schemaPath ?? '/__jsondb/schema'; + const manifestPath = options.manifestPath ?? '/__jsondb/manifest.json'; const eventsPath = options.eventsPath ?? '/__jsondb/events'; const importPath = options.importPath ?? '/__jsondb/import'; const restBatchPath = options.restBatchPath ?? '/__jsondb/batch'; @@ -190,6 +191,7 @@ export function renderJsonDbViewer(options = {}) {