Move browser authentication behind a generated-contract BFF
This commit is contained in:
parent
6f0f20f5cc
commit
fa7220ee33
3 changed files with 7259 additions and 2825 deletions
|
|
@ -3,6 +3,7 @@ import { readFileSync, writeFileSync } from "node:fs";
|
|||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { parse, stringify } from "yaml";
|
||||
import { format } from "prettier";
|
||||
|
||||
const root = dirname(fileURLToPath(import.meta.url));
|
||||
const repository = resolve(root, "../..");
|
||||
|
|
@ -62,12 +63,31 @@ for (const schema of Object.values(bundle.$defs)) {
|
|||
delete schema.$schema;
|
||||
delete schema.$id;
|
||||
}
|
||||
assertWebSchemasSupported(bundle.$defs);
|
||||
const events = {
|
||||
...bundle,
|
||||
$id: "https://remora.invalid/api/events.schema.json",
|
||||
title: "Remora WebSocket frame",
|
||||
$ref: "#/$defs/EventFrame",
|
||||
};
|
||||
const webContracts = await format(emitTypeScriptContracts(bundle.$defs), {
|
||||
parser: "typescript",
|
||||
semi: false,
|
||||
singleQuote: true,
|
||||
});
|
||||
const webSchemas = await format(
|
||||
`export const schemas = ${JSON.stringify(bundle.$defs, null, 2)} as const\n`,
|
||||
{
|
||||
parser: "typescript",
|
||||
semi: false,
|
||||
singleQuote: true,
|
||||
},
|
||||
);
|
||||
const webDecoders = await format(emitTypeScriptDecoders(), {
|
||||
parser: "typescript",
|
||||
semi: false,
|
||||
singleQuote: true,
|
||||
});
|
||||
const outputs = [
|
||||
[resolve(repository, "docs/api/openapi.yaml"), openapi],
|
||||
[resolve(repository, "docs/api/events.schema.json"), `${JSON.stringify(events, null, 2)}\n`],
|
||||
|
|
@ -75,6 +95,9 @@ const outputs = [
|
|||
resolve(repository, "crates/remora-contracts/src/generated_wire.rs"),
|
||||
readFileSync(generatedRust, "utf8"),
|
||||
],
|
||||
[resolve(repository, "web/src/api/contracts.ts"), webContracts],
|
||||
[resolve(repository, "web/src/api/generated-schema.ts"), webSchemas],
|
||||
[resolve(repository, "web/src/api/decode.ts"), webDecoders],
|
||||
];
|
||||
|
||||
let stale = false;
|
||||
|
|
@ -105,3 +128,179 @@ function rewriteBundleReferences(value) {
|
|||
}
|
||||
Object.values(value).forEach(rewriteBundleReferences);
|
||||
}
|
||||
|
||||
function emitTypeScriptContracts(definitions) {
|
||||
const lines = [
|
||||
"// Generated from contracts/typespec/main.tsp. Do not edit.",
|
||||
"",
|
||||
];
|
||||
for (const [name, schema] of Object.entries(definitions)) {
|
||||
lines.push(`export type ${name}Dto = ${typeScriptType(schema)}`, "");
|
||||
}
|
||||
return `${lines.join("\n")}\n`;
|
||||
}
|
||||
|
||||
function typeScriptType(schema) {
|
||||
if (schema.$ref) {
|
||||
return `${schema.$ref.replace(/^#\/\$defs\//, "").replace(/\.json$/, "")}Dto`;
|
||||
}
|
||||
if (schema.const !== undefined) {
|
||||
return JSON.stringify(schema.const);
|
||||
}
|
||||
if (schema.enum) {
|
||||
return schema.enum.map((value) => JSON.stringify(value)).join(" | ");
|
||||
}
|
||||
const alternatives = schema.oneOf ?? schema.anyOf;
|
||||
if (alternatives) {
|
||||
return alternatives.map(typeScriptType).join(" | ");
|
||||
}
|
||||
if (schema.allOf || schema.type === "object" || schema.properties) {
|
||||
const members = [];
|
||||
if (schema.allOf) {
|
||||
members.push(...schema.allOf.map(typeScriptType));
|
||||
}
|
||||
if (schema.properties || schema.type === "object") {
|
||||
const required = new Set(schema.required ?? []);
|
||||
const properties = Object.entries(schema.properties ?? {}).map(
|
||||
([name, property]) =>
|
||||
`${propertyName(name)}${required.has(name) ? "" : "?"}: ${typeScriptType(property)}`,
|
||||
);
|
||||
if (schema.additionalProperties && typeof schema.additionalProperties === "object") {
|
||||
properties.push(`[key: string]: ${typeScriptType(schema.additionalProperties)}`);
|
||||
}
|
||||
members.push(`{ ${properties.join("; ")} }`);
|
||||
}
|
||||
return members.length === 0 ? "Record<string, never>" : members.join(" & ");
|
||||
}
|
||||
if (schema.type === "array") {
|
||||
return `Array<${typeScriptType(schema.items)}>`;
|
||||
}
|
||||
if (schema.type === "string") return "string";
|
||||
if (schema.type === "integer" || schema.type === "number") return "number";
|
||||
if (schema.type === "boolean") return "boolean";
|
||||
if (schema.type === "null") return "null";
|
||||
throw new Error(`TypeScript schema emission does not support ${JSON.stringify(schema)}`);
|
||||
}
|
||||
|
||||
function propertyName(name) {
|
||||
return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name) ? name : JSON.stringify(name);
|
||||
}
|
||||
|
||||
function assertWebSchemasSupported(definitions) {
|
||||
for (const [name, schema] of Object.entries(definitions)) {
|
||||
assertWebSchemaSupported(schema, name);
|
||||
}
|
||||
}
|
||||
|
||||
function assertWebSchemaSupported(schema, location) {
|
||||
const allowed = new Set([
|
||||
"$ref",
|
||||
"type",
|
||||
"properties",
|
||||
"required",
|
||||
"items",
|
||||
"oneOf",
|
||||
"anyOf",
|
||||
"allOf",
|
||||
"const",
|
||||
"enum",
|
||||
"additionalProperties",
|
||||
"unevaluatedProperties",
|
||||
"minItems",
|
||||
"maxItems",
|
||||
"minLength",
|
||||
"maxLength",
|
||||
"pattern",
|
||||
"format",
|
||||
"minimum",
|
||||
"maximum",
|
||||
"description",
|
||||
"default",
|
||||
"not",
|
||||
]);
|
||||
for (const key of Object.keys(schema)) {
|
||||
if (!allowed.has(key)) {
|
||||
throw new Error(`Web schema emission does not support ${location}.${key}`);
|
||||
}
|
||||
}
|
||||
if (
|
||||
schema.format !== undefined &&
|
||||
schema.format !== "uri" &&
|
||||
schema.format !== "date-time"
|
||||
) {
|
||||
throw new Error(`Web schema emission does not support ${location} format ${schema.format}`);
|
||||
}
|
||||
for (const [name, property] of Object.entries(schema.properties ?? {})) {
|
||||
assertWebSchemaSupported(property, `${location}.${name}`);
|
||||
}
|
||||
if (schema.items) assertWebSchemaSupported(schema.items, `${location}[]`);
|
||||
for (const [kind, alternatives] of [
|
||||
["oneOf", schema.oneOf],
|
||||
["anyOf", schema.anyOf],
|
||||
["allOf", schema.allOf],
|
||||
]) {
|
||||
alternatives?.forEach((member, index) =>
|
||||
assertWebSchemaSupported(member, `${location}.${kind}[${index}]`),
|
||||
);
|
||||
}
|
||||
if (schema.additionalProperties) {
|
||||
assertWebSchemaSupported(schema.additionalProperties, `${location}.*`);
|
||||
}
|
||||
if (schema.unevaluatedProperties) {
|
||||
const keys = Object.keys(schema.unevaluatedProperties);
|
||||
if (
|
||||
keys.length === 1 &&
|
||||
keys[0] === "not" &&
|
||||
Object.keys(schema.unevaluatedProperties.not).length === 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
assertWebSchemaSupported(schema.unevaluatedProperties, `${location}.*`);
|
||||
}
|
||||
if (schema.not !== undefined) {
|
||||
throw new Error(`Web schema emission does not support general negation at ${location}`);
|
||||
}
|
||||
}
|
||||
|
||||
function emitTypeScriptDecoders() {
|
||||
const roots = [
|
||||
["JobFailure", "JobFailure"],
|
||||
["JobSubmission", "JobSubmission"],
|
||||
["EventFrame", "EventFrame"],
|
||||
["EventTicket", "EventTicket"],
|
||||
["JobPage", "JobPage"],
|
||||
["JobResource", "JobResource"],
|
||||
["DeletionOperation", "DeletionOperation"],
|
||||
["AttemptPage", "AttemptPage"],
|
||||
["ResultDescriptor", "ResultDescriptor"],
|
||||
["ResultMemberPage", "ResultMemberPage"],
|
||||
["ResultMemberAccess", "ResultMemberAccess"],
|
||||
["SourceCredential", "SourceCredential"],
|
||||
["SourceCredentialPage", "SourceCredentialPage"],
|
||||
["ApiError", "ApiError"],
|
||||
["AttemptOutcome", "AttemptOutcomeRequest"],
|
||||
];
|
||||
const imports = roots
|
||||
.map(([, type]) => ` ${type}Dto,`)
|
||||
.join("\n");
|
||||
const functions = roots
|
||||
.map(
|
||||
([functionName, type]) =>
|
||||
`function assert${functionName}(value: unknown): asserts value is ${type}Dto {\n` +
|
||||
` validateSchema('${type}', value)\n` +
|
||||
`}\n\n` +
|
||||
`export function decode${functionName}(value: unknown): ${type}Dto {\n` +
|
||||
` assert${functionName}(value)\n` +
|
||||
` return value\n` +
|
||||
`}`,
|
||||
)
|
||||
.join("\n\n");
|
||||
return `// Generated from contracts/typespec/main.tsp. Do not edit.
|
||||
import type {
|
||||
${imports}
|
||||
} from './contracts'
|
||||
import { validateSchema } from './validate'
|
||||
|
||||
${functions}
|
||||
`;
|
||||
}
|
||||
|
|
|
|||
1
contracts/typespec/package-lock.json
generated
1
contracts/typespec/package-lock.json
generated
|
|
@ -12,6 +12,7 @@
|
|||
"@typespec/json-schema": "1.14.0",
|
||||
"@typespec/openapi": "1.14.0",
|
||||
"@typespec/openapi3": "1.14.0",
|
||||
"prettier": "3.9.5",
|
||||
"yaml": "2.9.0"
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@
|
|||
"@typespec/json-schema": "1.14.0",
|
||||
"@typespec/openapi": "1.14.0",
|
||||
"@typespec/openapi3": "1.14.0",
|
||||
"prettier": "3.9.5",
|
||||
"yaml": "2.9.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue