Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 31 additions & 1 deletion packages/schemas/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,31 @@ const appSchema = getSchema('app-definition');
console.log('App definition schema:', appSchema);
```

## Schema Identifiers and Hosting

Every schema's `$id` is its public URL:

```
https://friggframework.org/schemas/<name>/v<major>.<minor>.json
```

for example `https://friggframework.org/schemas/app-definition/v1.0.json`.
friggframework.org serves each file at that URL (with
`Content-Type: application/schema+json` and open CORS), so editors can fetch
it from a `$schema` reference. The URL is a name, not a dependency: this
package, `frigg validate` and the runtime resolve every `$id` from the
installed files and never fetch it.

- A version is immutable once a stable release ships it. A backward-compatible
change gets a new minor version, a breaking change a new major version.
- `validate()` and `getSchema()` accept a schema name, its `$id`, or the
unversioned id it had before 2.0.1 (kept as an alias for one release).
- The website copies live in `website/schemas/` and are committed. After you
change a schema, run `npm run sync:hosted` here and commit the result;
`tests/hosted-schemas.test.js` fails while a copy is missing or stale.

The rules are in ADR-051, "Hosted schemas" amendment.

## Available Schemas

### App Definition Schema
Expand Down Expand Up @@ -353,7 +378,12 @@ Validates core data models (User, Entity, Credential).
Returns all available schemas as an object.

#### `getSchema(schemaName)`
Returns a specific schema by name.
Returns a specific schema by name (`app-definition`), by `$id`, or by its legacy unversioned id.

#### `parseSchemaId(id)`
Returns `{ name, version, hostedPath }` for an `$id` on the
`https://friggframework.org/schemas/<name>/v<x.y>.json` pattern, or `null`.
`SCHEMA_ID_PATTERN` and `LEGACY_SCHEMA_IDS` are exported too.

### Utility Functions

Expand Down
26 changes: 21 additions & 5 deletions packages/schemas/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ const {
applyAppDefinitionDefaults,
toAppDefinitionSchemaView,
} = require('./app-definition');
const schemaIds = require('./schema-ids');

// Initialize AJV with formats
const ajv = new Ajv({
Expand Down Expand Up @@ -49,7 +50,16 @@ schemaFiles.forEach(file => {
const schemaName = file.replace('.schema.json', '');

schemas[schemaName] = schemaContent;
ajv.addSchema(schemaContent, schemaName);
schemaIds.addSchemaWithAliases(ajv, schemaContent, schemaName);
});

// Schema name for each `$id` and legacy id, so getSchema() also accepts a URL.
const schemaNamesById = {};
Object.entries(schemas).forEach(([schemaName, schema]) => {
schemaNamesById[schema.$id] = schemaName;
(schemaIds.LEGACY_SCHEMA_IDS[schemaName] || []).forEach(legacyId => {
schemaNamesById[legacyId] = schemaName;
});
});

/**
Expand Down Expand Up @@ -332,15 +342,16 @@ function getSchemas() {
}

/**
* Get a specific schema by name
* @param {string} schemaName - Name of the schema to retrieve
* Get a specific schema by name or `$id`
* @param {string} schemaName - Schema name (`app-definition`), its `$id`, or a legacy unversioned id
* @returns {object} - Schema object
*/
function getSchema(schemaName) {
if (!schemas[schemaName]) {
const name = schemas[schemaName] ? schemaName : schemaNamesById[schemaName];
if (!name) {
throw new Error(`Schema '${schemaName}' not found. Available schemas: ${Object.keys(schemas).join(', ')}`);
}
return schemas[schemaName];
return schemas[name];
}

/**
Expand Down Expand Up @@ -401,6 +412,11 @@ module.exports = {
schemas,
ajv,

// Schema identifiers (https://friggframework.org/schemas/<name>/v<x.y>.json)
SCHEMA_ID_PATTERN: schemaIds.SCHEMA_ID_PATTERN,
LEGACY_SCHEMA_IDS: schemaIds.LEGACY_SCHEMA_IDS,
parseSchemaId: schemaIds.parseSchemaId,

// Middleware exports
middleware: schemaValidationMiddleware,
validateBody: schemaValidationMiddleware.validateBody,
Expand Down
3 changes: 2 additions & 1 deletion packages/schemas/middleware/schema-validation.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ const Ajv = require('ajv');
const addFormats = require('ajv-formats');
const fs = require('fs');
const path = require('path');
const { addSchemaWithAliases } = require('../schema-ids');

// Initialize AJV with formats
const ajv = new Ajv({
Expand All @@ -33,7 +34,7 @@ schemaFiles.forEach(file => {
if (fs.existsSync(schemaPath)) {
const schemaContent = JSON.parse(fs.readFileSync(schemaPath, 'utf8'));
const schemaName = file.replace('.schema.json', '');
ajv.addSchema(schemaContent, schemaName);
addSchemaWithAliases(ajv, schemaContent, schemaName);
}
});

Expand Down
5 changes: 4 additions & 1 deletion packages/schemas/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@
"scripts": {
"test": "jest",
"validate": "node scripts/validate-schemas.js",
"build": "node scripts/build-schemas.js"
"build": "node scripts/build-schemas.js",
"sync:hosted": "node scripts/sync-hosted-schemas.js",
"check:hosted": "node scripts/sync-hosted-schemas.js --check"
},
"keywords": [
"frigg",
Expand All @@ -40,6 +42,7 @@
"mocks/",
"middleware/",
"app-definition.js",
"schema-ids.js",
"index.js"
]
}
69 changes: 69 additions & 0 deletions packages/schemas/schema-ids.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
/**
* Schema identifiers (ADR-051, "Hosted schemas" amendment).
*
* Every Frigg JSON Schema has the `$id`
* https://friggframework.org/schemas/<name>/v<major>.<minor>.json
* and friggframework.org serves the file at exactly that URL. The `$id` is a
* name, not a location the runtime reads: validation always resolves it from
* this package and never fetches it.
*/

const SCHEMA_BASE_URL = 'https://friggframework.org/';

const SCHEMA_ID_PATTERN =
/^https:\/\/friggframework\.org\/schemas\/([a-z0-9]+(?:-[a-z0-9]+)*)\/v(0|[1-9]\d*)\.(0|[1-9]\d*)\.json$/;

/**
* Unversioned ids the schemas carried before 2.0.1. They never resolved over
* HTTP, but code may pass them to `validate()`/`getSchema()` or Ajv, so the
* package still registers them as aliases of the current schema.
*
* @deprecated Remove in the release after 2.0.1.
*/
const LEGACY_SCHEMA_IDS = Object.freeze({
'api-authorization': ['https://friggframework.org/schemas/api-authorization.json'],
'api-credentials': ['https://friggframework.org/schemas/api-credentials.json'],
'api-entities': ['https://friggframework.org/schemas/api-entities.json'],
'api-proxy': ['https://friggframework.org/schemas/api-proxy.json'],
'api-module-definition': ['https://schemas.friggframework.org/api-module-definition.schema.json'],
'app-definition': ['https://schemas.friggframework.org/app-definition.schema.json'],
'core-models': ['https://schemas.friggframework.org/core-models.schema.json'],
'environment-config': ['https://schemas.friggframework.org/environment-config.schema.json'],
'integration-definition': ['https://schemas.friggframework.org/integration-definition.schema.json'],
'serverless-config': ['https://schemas.friggframework.org/serverless-config.schema.json'],
});

/**
* Parse a schema `$id`.
* @param {string} id
* @returns {{ name: string, version: string, hostedPath: string } | null}
* `hostedPath` is the path under the website root (`schemas/<name>/v1.0.json`).
*/
function parseSchemaId(id) {
const match = typeof id === 'string' ? SCHEMA_ID_PATTERN.exec(id) : null;
if (!match) return null;
const [, name, major, minor] = match;
const version = `${major}.${minor}`;
return { name, version, hostedPath: `schemas/${name}/v${version}.json` };
}

/**
* Register a schema with Ajv under its file name, its `$id`, and its legacy ids.
* @param {import('ajv').default} ajv
* @param {object} schema - parsed schema (its `$id` registers automatically)
* @param {string} name - file name without `.schema.json`
*/
function addSchemaWithAliases(ajv, schema, name) {
ajv.addSchema(schema, name);
for (const legacyId of LEGACY_SCHEMA_IDS[name] || []) {
ajv.addSchema({ ...schema, $id: legacyId });
}
}

module.exports = {
SCHEMA_BASE_URL,
SCHEMA_ID_PATTERN,
LEGACY_SCHEMA_IDS,
parseSchemaId,
addSchemaWithAliases,
};
2 changes: 1 addition & 1 deletion packages/schemas/schemas/api-authorization.schema.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://friggframework.org/schemas/api-authorization.json",
"$id": "https://friggframework.org/schemas/api-authorization/v1.0.json",
"title": "Frigg Authorization API Schemas",
"description": "JSON schemas for authorization and authentication API requests and responses",
"definitions": {
Expand Down
2 changes: 1 addition & 1 deletion packages/schemas/schemas/api-credentials.schema.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://friggframework.org/schemas/api-credentials.json",
"$id": "https://friggframework.org/schemas/api-credentials/v1.0.json",
"title": "Frigg Credentials API Schemas",
"description": "JSON schemas for credentials API requests and responses",
"definitions": {
Expand Down
2 changes: 1 addition & 1 deletion packages/schemas/schemas/api-entities.schema.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://friggframework.org/schemas/api-entities.json",
"$id": "https://friggframework.org/schemas/api-entities/v1.0.json",
"title": "Frigg Entities API Schemas",
"description": "JSON schemas for entities API requests and responses",
"definitions": {
Expand Down
2 changes: 1 addition & 1 deletion packages/schemas/schemas/api-module-definition.schema.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://schemas.friggframework.org/api-module-definition.schema.json",
"$id": "https://friggframework.org/schemas/api-module-definition/v1.0.json",
"title": "Frigg API Module Definition",
"description": "Schema for defining a Frigg API module including authentication, API configuration, and required methods.",
"type": "object",
Expand Down
2 changes: 1 addition & 1 deletion packages/schemas/schemas/api-proxy.schema.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://friggframework.org/schemas/api-proxy.json",
"$id": "https://friggframework.org/schemas/api-proxy/v1.0.json",
"title": "Frigg Proxy API Schemas",
"description": "JSON schemas for proxy API requests and responses that forward calls to external APIs",
"definitions": {
Expand Down
2 changes: 1 addition & 1 deletion packages/schemas/schemas/app-definition.schema.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://schemas.friggframework.org/app-definition.schema.json",
"$id": "https://friggframework.org/schemas/app-definition/v1.0.json",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

blocking (merge order with #666): this renames $id to …/app-definition/v1.0.json but the document body is still the pre-#666 schema. ADR-051’s hosted-schemas amendment makes v1.0 immutable once published.

Scenario: Ship this before #666 → public v1.0 rejects postgres.management: 'external' and fieldLevelEncryptionMethod: 'none', then #666 cannot safely overwrite the same URL. Sync hosted copies from the post-#666 schema (with those enums) before freezing v1.0.

"title": "Frigg Application Definition",
"description": "Schema for defining a Frigg application configuration including integrations, infrastructure, security settings, and deployment options.",
"type": "object",
Expand Down
2 changes: 1 addition & 1 deletion packages/schemas/schemas/core-models.schema.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://schemas.friggframework.org/core-models.schema.json",
"$id": "https://friggframework.org/schemas/core-models/v1.0.json",
"title": "Frigg Core Data Models",
"description": "Schema definitions for core Frigg data models including User, Entity, and Credential.",
"definitions": {
Expand Down
2 changes: 1 addition & 1 deletion packages/schemas/schemas/environment-config.schema.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://schemas.friggframework.org/environment-config.schema.json",
"$id": "https://friggframework.org/schemas/environment-config/v1.0.json",
"title": "Frigg Environment Configuration",
"description": "Schema for environment variable configuration and validation in Frigg applications.",
"type": "object",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://schemas.friggframework.org/integration-definition.schema.json",
"$id": "https://friggframework.org/schemas/integration-definition/v1.0.json",
"title": "Frigg Integration Definition",
"description": "Schema for defining a Frigg integration including configuration, options, and display properties.",
"type": "object",
Expand Down
2 changes: 1 addition & 1 deletion packages/schemas/schemas/serverless-config.schema.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://schemas.friggframework.org/serverless-config.schema.json",
"$id": "https://friggframework.org/schemas/serverless-config/v1.0.json",
"title": "Frigg Serverless Configuration",
"description": "Schema for Serverless Framework configuration used in Frigg applications for AWS deployment.",
"type": "object",
Expand Down
102 changes: 102 additions & 0 deletions packages/schemas/scripts/sync-hosted-schemas.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
#!/usr/bin/env node

/**
* Copy the Frigg JSON Schemas into the website, at the path their `$id` names.
*
* https://friggframework.org/schemas/<name>/v<x.y>.json
* -> website/schemas/<name>/v<x.y>.json
*
* The copies are committed: friggframework.org is published from website/ as
* static files, and a file stays in website/schemas after its schema moves to
* a new version, so every published version keeps resolving.
*
* node scripts/sync-hosted-schemas.js write the copies
* node scripts/sync-hosted-schemas.js --check exit 1 when a copy is missing or stale
*
* See ADR-051, "Hosted schemas" amendment.
*/

const fs = require('fs');

Check warning on line 19 in packages/schemas/scripts/sync-hosted-schemas.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `node:fs` over `fs`.

See more on https://sonarcloud.io/project/issues?id=friggframework_frigg&issues=AaFpbbuqxk1pJB6p1Uo0&open=AaFpbbuqxk1pJB6p1Uo0&pullRequest=705
const path = require('path');

Check warning on line 20 in packages/schemas/scripts/sync-hosted-schemas.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `node:path` over `path`.

See more on https://sonarcloud.io/project/issues?id=friggframework_frigg&issues=AaFpbbuqxk1pJB6p1Uo1&open=AaFpbbuqxk1pJB6p1Uo1&pullRequest=705
const { parseSchemaId } = require('../schema-ids');

const REPO_ROOT = path.resolve(__dirname, '..', '..', '..');
const WEBSITE_ROOT = path.join(REPO_ROOT, 'website');

// Directories whose *.schema.json files are published. A directory that does
// not exist on this branch is skipped.
const SOURCE_DIRS = [
'packages/schemas/schemas',
'packages/core/integrations/definitions/schema',
];

/**
* @returns {Array<{ source: string, id: string, hostedPath: string, target: string, content: string }>}
* @throws when a schema has no `$id` on the pattern, or two schemas claim one URL
*/
function planHostedSchemas() {
const plan = [];
const byHostedPath = new Map();

for (const dir of SOURCE_DIRS) {
const absDir = path.join(REPO_ROOT, dir);
if (!fs.existsSync(absDir)) continue;

for (const file of fs.readdirSync(absDir).filter(f => f.endsWith('.schema.json')).sort()) {
const source = path.posix.join(dir, file);
const content = fs.readFileSync(path.join(absDir, file), 'utf8');
const id = JSON.parse(content).$id;
const parsed = parseSchemaId(id);
if (!parsed) {
throw new Error(
`${source}: $id ${JSON.stringify(id)} is not https://friggframework.org/schemas/<name>/v<major>.<minor>.json`
);
}
if (byHostedPath.has(parsed.hostedPath)) {
throw new Error(
`${source} and ${byHostedPath.get(parsed.hostedPath)} both have the $id ${id}`
);
}
byHostedPath.set(parsed.hostedPath, source);
plan.push({
source,
id,
hostedPath: parsed.hostedPath,
target: path.join(WEBSITE_ROOT, parsed.hostedPath),
content,
});
}
}
return plan;
}

function main() {
const check = process.argv.includes('--check');
const plan = planHostedSchemas();
const stale = [];

for (const entry of plan) {
const current = fs.existsSync(entry.target) ? fs.readFileSync(entry.target, 'utf8') : null;
if (current === entry.content) continue;
if (check) {
stale.push(entry);
continue;
}
fs.mkdirSync(path.dirname(entry.target), { recursive: true });
fs.writeFileSync(entry.target, entry.content);
console.log(`wrote website/${entry.hostedPath} (from ${entry.source})`);
}

if (check && stale.length > 0) {
for (const entry of stale) {
console.error(`website/${entry.hostedPath} is missing or differs from ${entry.source}`);
}
console.error('Run `npm run sync:hosted --workspace=packages/schemas` and commit the result.');
process.exit(1);
}
if (check) console.log(`${plan.length} hosted schemas in sync.`);
}

if (require.main === module) main();

module.exports = { planHostedSchemas, REPO_ROOT, WEBSITE_ROOT, SOURCE_DIRS };
Loading
Loading