Skip to content
Closed
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
5 changes: 5 additions & 0 deletions .changeset/openapi-route-metadata.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@adobe/aio-commerce-lib-app": minor
---

Add introspection capability for all the actions and a new `GET /openapi.json` for `lib-app`.
1 change: 1 addition & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
# Collapse auto-generated API reference diffs in GitHub PRs (e.g. the Version Packages PR)
packages/*/docs/api-reference/** linguist-generated=true
packages-private/*/docs/api-reference/** linguist-generated=true
packages/aio-commerce-lib-app/generated/** linguist-generated=true
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
"@biomejs/biome": "2.4.15",
"@changesets/changelog-github": "^0.7.0",
"@changesets/cli": "^2.30.0",
"@redocly/cli": "^2.31.4",
"@turbo/gen": "^2.8.17",
"@types/node": "^24.12.0",
"@vitest/coverage-v8": "^4.1.0",
Expand Down
35 changes: 34 additions & 1 deletion packages-private/common-utils/docs/guides/http-action-router.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ The HTTP Action Router simplifies these common patterns with a fluent, Express-l

```typescript
import { HttpActionRouter } from "@aio-commerce-sdk/common-utils/actions";
import { ok, notFound } from "@adobe/aio-commerce-lib-core/responses";
import { created, ok } from "@adobe/aio-commerce-lib-core/responses";

const router = new HttpActionRouter();

Expand Down Expand Up @@ -145,6 +145,39 @@ router.get("/users", {
});
```

## Route Metadata

Routes can include `metadata` for documentation and introspection. The OpenAPI generator uses this metadata for operation fields and response descriptions. Set `internal: true` to omit a route from the generated public OpenAPI document.
When metadata does not provide `summary` or `operationId`, the generator derives stable defaults from the HTTP method and OpenAPI path.

```typescript
router.get("/users/:id", {
metadata: {
summary: "Get user",
description: "Returns a user by id.",
operationId: "getUser",
tags: ["Users"],
responses: {
200: {
description: "User found.",
schema: userResponseSchema,
},
},
security: [{ imsOAuth: ["read:user"] }],
},
params: userIdSchema,
handler: (req) => ok({ body: { id: req.params.id } }),
});

router.get("/openapi.json", {
metadata: { internal: true },
handler: () => ok({ body: openAPISpec }),
});
```

The generator also accepts document-level OpenAPI options for `servers`, `security`, and `securitySchemes` so generated specs can be validated by standard OpenAPI linters without post-processing.
Route-level `metadata.security` overrides the document-level security requirements when an endpoint needs different OAuth scopes.

## Request Object

The handler receives a request object with the following properties:
Expand Down
3 changes: 3 additions & 0 deletions packages-private/common-utils/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
},
"exports": {
"./actions": "./source/actions/index.ts",
"./actions/openapi": "./source/actions/http/openapi/index.ts",
"./storage": "./source/storage/index.ts",
"./valibot": "./source/valibot/index.ts",
"./logging": "./source/logging.ts",
Expand All @@ -41,6 +42,7 @@
"@adobe/aio-lib-files": "catalog:",
"@adobe/aio-lib-state": "catalog:",
"@standard-schema/spec": "^1.1.0",
"@valibot/to-json-schema": "^1.6.0",
"regexparam": "^3.0.0",
"type-fest": "catalog:",
"valibot": "catalog:"
Expand All @@ -50,6 +52,7 @@
"@aio-commerce-sdk/config-typedoc": "workspace:*",
"@aio-commerce-sdk/config-typescript": "workspace:*",
"@aio-commerce-sdk/config-vitest": "workspace:*",
"openapi3-ts": "^4.5.0",
"typescript": "catalog:"
},
"sideEffects": false
Expand Down
21 changes: 21 additions & 0 deletions packages-private/common-utils/source/actions/http/openapi/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/*
* Copyright 2026 Adobe. All rights reserved.
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. You may obtain a copy
* of the License at http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
* OF ANY KIND, either express or implied. See the License for the specific language
* governing permissions and limitations under the License.
*/

// biome-ignore lint/performance/noBarrelFile: Public API entrypoint
export { generateOpenAPISpec } from "./spec";

export type {
OpenAPIGenerationOptions,
OpenAPIInfo,
OpenAPISpec,
RouterEntry,
} from "./types";
244 changes: 244 additions & 0 deletions packages-private/common-utils/source/actions/http/openapi/schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,244 @@
/*
* Copyright 2026 Adobe. All rights reserved.
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. You may obtain a copy
* of the License at http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
* OF ANY KIND, either express or implied. See the License for the specific language
* governing permissions and limitations under the License.
*/

import { toJsonSchema } from "@valibot/to-json-schema";

import type { StandardSchemaV1 } from "@standard-schema/spec";
import type { JsonSchema } from "@valibot/to-json-schema";
import type { GenericSchema } from "valibot";
import type {
ComponentSchemas,
ConvertedSchema,
JsonSchemaDefinition,
JsonSchemaObject,
OpenAPISchema,
SchemaConversionContext,
} from "./types";

/** Converts JSON Schema boolean schemas to their OpenAPI schema equivalent. */
export function toOpenAPISchema(schema: JsonSchemaDefinition): OpenAPISchema {
if (typeof schema !== "boolean") {
return schema as OpenAPISchema;
}

return schema ? {} : { not: {} };
}

/** Normalizes an explicit schema title into an OpenAPI component name. */
function sanitizeComponentName(name: string): string {
return name.replaceAll(/\s+/g, "").replaceAll(/[^A-Za-z0-9._-]/g, "");
}

/** Reads an explicit JSON Schema title, when present. */
function schemaTitle(schema: JsonSchemaDefinition): string | undefined {
if (typeof schema !== "object" || schema === null) {
return;
}

if (typeof schema.title === "string") {
return schema.title;
}
}

/** Returns the component name for a hoisted definition. */
function definitionComponentName(
definition: JsonSchemaDefinition,
referenceId: string,
): string {
const title = schemaTitle(definition);
const name = title ? sanitizeComponentName(title) : undefined;

if (!name) {
throw new Error(
`OpenAPI component schema "${referenceId}" must define a title`,
);
}

return name;
}

/** Removes JSON Schema-only metadata before embedding a schema in OpenAPI. */
function stripSchemaAnnotations(schema: JsonSchema): OpenAPISchema {
const {
$defs: _defs,
$schema: _schema,
...body
} = schema as JsonSchemaObject;

return toOpenAPISchema(body);
}

/** Builds a local OpenAPI component reference. */
function componentRef(name: string): string {
return `#/components/schemas/${name}`;
}

/** Maps local JSON Schema definition ids to explicit component names. */
function collectDefinitionNames(
definitions: Record<string, JsonSchemaDefinition> | undefined,
names: Map<string, string>,
) {
if (!definitions) {
return;
}

for (const [referenceId, definition] of Object.entries(definitions)) {
const name = definitionComponentName(definition, referenceId);
const nestedDefinitions =
typeof definition === "object" && definition !== null
? definition.$defs
: undefined;

names.set(referenceId, name);
collectDefinitionNames(nestedDefinitions, names);
}
}

/** Rewrites local definition refs to their final OpenAPI component refs. */
function rewriteSchemaRefs(
node: JsonSchemaDefinition,
names: Map<string, string>,
): JsonSchemaDefinition {
if (typeof node !== "object" || node === null) {
return node;
}

if (Array.isArray(node)) {
return node.map((item) =>
rewriteSchemaRefs(item, names),
) as JsonSchemaDefinition;
}

const result: Record<string, unknown> = {};

for (const [key, value] of Object.entries(node)) {
if (key === "$ref" && typeof value === "string") {
const localRef = value.replace("#/components/schemas/", "");
const name = names.get(localRef);
result[key] = name ? componentRef(name) : value;

continue;
}

result[key] = rewriteSchemaRefs(value as JsonSchemaDefinition, names);
}

return result as JsonSchemaDefinition;
}

/** Compares schema objects after conversion. */
function schemasMatch(left: OpenAPISchema, right: OpenAPISchema): boolean {
return JSON.stringify(left) === JSON.stringify(right);
}

/** Collects hoisted JSON Schema definitions as OpenAPI components. */
function collectComponents(
definitions: Record<string, JsonSchemaDefinition> | undefined,
names: Map<string, string>,
components: ComponentSchemas,
): ComponentSchemas {
if (!definitions) {
return components;
}

for (const [referenceId, definition] of Object.entries(definitions)) {
const name = names.get(referenceId);

if (!name) {
continue;
}

const existing = components[name];
const component = stripSchemaAnnotations(
toOpenAPISchema(rewriteSchemaRefs(definition, names)),
);

if (existing && !schemasMatch(existing, component)) {
throw new Error(
`OpenAPI component title "${name}" resolves to multiple schemas`,
);
}

components[name] = component;

const nestedDefinitions =
typeof definition === "object" && definition !== null
? definition.$defs
: undefined;

collectComponents(nestedDefinitions, names, components);
}

return components;
}

/** Registers converted components and rejects conflicting explicit titles. */
function registerDefinitions(
definitions: Record<string, JsonSchemaDefinition> | undefined,
names: Map<string, string>,
context: SchemaConversionContext,
) {
const components = collectComponents(definitions, names, {});

for (const [name, component] of Object.entries(components)) {
const existing = context.components[name];

if (existing && !schemasMatch(existing, component)) {
throw new Error(
`OpenAPI component title "${name}" resolves to multiple schemas`,
);
}
}

Object.assign(context.components, components);
}

/** Converts a schema to OpenAPI using Valibot's JSON Schema converter. */
export function convertSchema(
schema: StandardSchemaV1,
context: SchemaConversionContext,
): ConvertedSchema | null {
if (schema["~standard"].vendor !== "valibot") {
throw new Error(
"Currently, only Valibot schemas are supported for OpenAPI conversion",
);
}

const names = new Map<string, string>();

try {
const source = toJsonSchema(schema as GenericSchema, {
errorMode: "throw",
target: "draft-2020-12",

overrideRef: ({ referenceId }) => componentRef(referenceId),
});

collectDefinitionNames(source.$defs, names);
registerDefinitions(source.$defs, names, context);

return {
source,
schema: stripSchemaAnnotations(
toOpenAPISchema(rewriteSchemaRefs(source, names)),
),
};
} catch (error) {
if (context.options.schemaErrorMode !== "throw") {
return null;
}

throw new Error("Failed to convert Valibot schema to JSON Schema", {
cause: error,
});
}
}
Loading