From 3c06da305f1b1a23b13c2ef32833f43fcb9f21f7 Mon Sep 17 00:00:00 2001 From: Giulio Canti Date: Wed, 8 Jan 2025 19:30:47 +0100 Subject: [PATCH] Platform: add OpenAPI.fromApi tests --- packages/platform/README.md | 312 ++++++- packages/platform/src/HttpApi.ts | 3 + packages/platform/test/OpenApi.test.ts | 907 +++++++++++++++++++ packages/platform/test/fixtures/openapi.json | 647 +++++++++++++ packages/platform/vitest.config.ts | 9 +- 5 files changed, 1847 insertions(+), 31 deletions(-) create mode 100644 packages/platform/test/OpenApi.test.ts create mode 100644 packages/platform/test/fixtures/openapi.json diff --git a/packages/platform/README.md b/packages/platform/README.md index 03a6f1b2c5a..e6f854a0de8 100644 --- a/packages/platform/README.md +++ b/packages/platform/README.md @@ -819,8 +819,9 @@ These security annotations can be used alongside `HttpApiMiddleware` to create m ```ts import { - HttpApiGroup, + HttpApi, HttpApiEndpoint, + HttpApiGroup, HttpApiMiddleware, HttpApiSchema, HttpApiSecurity @@ -860,14 +861,20 @@ class Authorization extends HttpApiMiddleware.Tag()( } ) {} -class UsersApi extends HttpApiGroup.make("users") +const api = HttpApi.make("api") .add( - HttpApiEndpoint.get("findById")`/${Schema.NumberFromString}` - // Apply the middleware to a single endpoint + HttpApiGroup.make("group") + .add( + HttpApiEndpoint.get("get", "/") + .addSuccess(Schema.String) + // Apply the middleware to a single endpoint + .middleware(Authorization) + ) + // Or apply the middleware to the entire group .middleware(Authorization) ) - // Or apply the middleware to the entire group - .middleware(Authorization) {} + // Or apply the middleware to the entire API + .middleware(Authorization) ``` ### Implementing HttpApiSecurity middleware @@ -1020,42 +1027,287 @@ Layer.launch(HttpLive).pipe(NodeRuntime.runMain) ### Adding OpenAPI Annotations -You can enhance your API documentation by adding OpenAPI annotations using the `OpenApi` module. These annotations allow you to include metadata such as titles, descriptions, and other details, making your API documentation more informative and easier to use. +You can add OpenAPI annotations to your API to include metadata such as titles, descriptions, and more. These annotations help generate richer API documentation. + +#### HttpApi -**Example** (Adding OpenAPI Annotations to a Group) +Below is a list of available annotations for a top-level `HttpApi`. They can be added using the `.annotate` method: -In this example: +| Annotation | Description | +| --------------------------- | ------------------------------------------------------------------------------------------------------------------ | +| `HttpApi.AdditionalSchemas` | Adds custom schemas to the final OpenAPI specification. Only schemas with an `identifier` annotation are included. | +| `OpenApi.Description` | Sets a general description for the API. | +| `OpenApi.License` | Defines the license used by the API. | +| `OpenApi.Summary` | Provides a brief summary of the API. | +| `OpenApi.Servers` | Lists server URLs and optional metadata such as variables. | +| `OpenApi.Override` | Merges the supplied fields into the resulting specification. | +| `OpenApi.Transform` | Allows you to modify the final specification with a custom function. | -- A title ("Users API") and description ("API for managing users") are added to the `UsersApi` group. -- These annotations will appear in the generated OpenAPI documentation. +**Example** (Annotating the Top-Level API) ```ts -import { OpenApi } from "@effect/platform" +import { HttpApi, OpenApi } from "@effect/platform" +import { Schema } from "effect" -class UsersApi extends HttpApiGroup.make("users").add( - HttpApiEndpoint.get("findById")`/users/${UserIdParam}` - .addSuccess(User) - // You can set one attribute at a time - .annotate(OpenApi.Title, "Users API") - // or multiple at once - .annotateContext( - OpenApi.annotations({ - title: "Users API", - description: "API for managing users" +const api = HttpApi.make("api") + // Provide additional schemas + .annotate(HttpApi.AdditionalSchemas, [ + Schema.String.annotations({ identifier: "MyString" }) + ]) + // Add a description + .annotate(OpenApi.Description, "my description") + // Set license information + .annotate(OpenApi.License, { name: "MIT", url: "http://example.com" }) + // Provide a summary + .annotate(OpenApi.Summary, "my summary") + // Define servers + .annotate(OpenApi.Servers, [ + { + url: "http://example.com", + description: "example", + variables: { a: { default: "b", enum: ["c"], description: "d" } } + } + ]) + // Override parts of the generated specification + .annotate(OpenApi.Override, { + tags: [{ name: "a", description: "a-description" }] + }) + // Apply a transform function to the final specification + .annotate(OpenApi.Transform, (spec) => ({ + ...spec, + tags: [...spec.tags, { name: "b", description: "b-description" }] + })) + +// Generate the OpenAPI specification from the annotated API +const spec = OpenApi.fromApi(api) + +console.log(JSON.stringify(spec, null, 2)) +/* +Output: +{ + "openapi": "3.1.0", + "info": { + "title": "Api", + "version": "0.0.1", + "description": "my description", + "license": { + "name": "MIT", + "url": "http://example.com" + }, + "summary": "my summary" + }, + "paths": {}, + "tags": [ + { "name": "a", "description": "a-description" }, + { "name": "b", "description": "b-description" } + ], + "components": { + "schemas": { + "MyString": { + "type": "string" + } + }, + "securitySchemes": {} + }, + "security": [], + "servers": [ + { + "url": "http://example.com", + "description": "example", + "variables": { + "a": { + "default": "b", + "enum": [ + "c" + ], + "description": "d" + } + } + } + ] +} +*/ +``` + +#### HttpApiGroup + +The following annotations can be added to an `HttpApiGroup`: + +| Annotation | Description | +| ---------------------- | --------------------------------------------------------------------- | +| `OpenApi.Description` | Sets a description for this group. | +| `OpenApi.ExternalDocs` | Provides external documentation links for the group. | +| `OpenApi.Override` | Merges specified fields into the resulting specification. | +| `OpenApi.Transform` | Lets you modify the final group specification with a custom function. | +| `OpenApi.Exclude` | Excludes the group from the final OpenAPI specification. | + +**Example** (Annotating a Group) + +```ts +import { HttpApi, HttpApiGroup, OpenApi } from "@effect/platform" + +const api = HttpApi.make("api") + .add( + HttpApiGroup.make("group") + // Add a description for the group + .annotate(OpenApi.Description, "my description") + // Provide external documentation links + .annotate(OpenApi.ExternalDocs, { + url: "http://example.com", + description: "example" }) - ) -) {} + // Override parts of the final output + .annotate(OpenApi.Override, { name: "my name" }) + // Transform the final specification for this group + .annotate(OpenApi.Transform, (spec) => ({ + ...spec, + name: spec.name + "-transformed" + })) + ) + .add( + HttpApiGroup.make("excluded") + // Exclude the group from the final specification + .annotate(OpenApi.Exclude, true) + ) + +// Generate the OpenAPI spec +const spec = OpenApi.fromApi(api) + +console.log(JSON.stringify(spec, null, 2)) +/* +Output: +{ + "openapi": "3.1.0", + "info": { + "title": "Api", + "version": "0.0.1" + }, + "paths": {}, + "tags": [ + { + "name": "my name-transformed", + "description": "my description", + "externalDocs": { + "url": "http://example.com", + "description": "example" + } + } + ], + "components": { + "schemas": {}, + "securitySchemes": {} + }, + "security": [] +} +*/ ``` -Annotations can also be applied to the entire API. In this example, a title ("My API") is added to the top-level `HttpApi`. +#### HttpApiEndpoint + +For an `HttpApiEndpoint`, you can use the following annotations: -**Example** (Adding OpenAPI Annotations to the Top-Level API) +| Annotation | Description | +| ---------------------- | --------------------------------------------------------------------------- | +| `OpenApi.Description` | Adds a description for this endpoint. | +| `OpenApi.Summary` | Provides a short summary of the endpoint's purpose. | +| `OpenApi.Deprecated` | Marks the endpoint as deprecated. | +| `OpenApi.ExternalDocs` | Supplies external documentation links for the endpoint. | +| `OpenApi.Override` | Merges specified fields into the resulting specification for this endpoint. | +| `OpenApi.Transform` | Lets you modify the final endpoint specification with a custom function. | +| `OpenApi.Exclude` | Excludes the endpoint from the final OpenAPI specification. | + +**Example** (Annotating an Endpoint) ```ts -class MyApi extends HttpApi.make("myApi") - .add(UsersApi) - // Add a title for the top-level API - .annotate(OpenApi.Title, "My API") {} +import { + HttpApi, + HttpApiEndpoint, + HttpApiGroup, + OpenApi +} from "@effect/platform" +import { Schema } from "effect" + +const api = HttpApi.make("api").add( + HttpApiGroup.make("group") + .add( + HttpApiEndpoint.get("get", "/") + .addSuccess(Schema.String) + // Add a description + .annotate(OpenApi.Description, "my description") + // Provide a summary + .annotate(OpenApi.Summary, "my summary") + // Mark the endpoint as deprecated + .annotate(OpenApi.Deprecated, true) + // Provide external documentation + .annotate(OpenApi.ExternalDocs, { + url: "http://example.com", + description: "example" + }) + ) + .add( + HttpApiEndpoint.get("excluded", "/excluded") + .addSuccess(Schema.String) + // Exclude this endpoint from the final specification + .annotate(OpenApi.Exclude, true) + ) +) + +// Generate the OpenAPI spec +const spec = OpenApi.fromApi(api) + +console.log(JSON.stringify(spec, null, 2)) +/* +Output: +{ + "openapi": "3.1.0", + "info": { + "title": "Api", + "version": "0.0.1" + }, + "paths": { + "/": { + "get": { + "tags": [ + "group" + ], + "operationId": "my operationId-transformed", + "parameters": [], + "security": [], + "responses": { + "200": { + "description": "a string", + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + } + }, + "400": { + "description": "The request did not match the expected schema", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HttpApiDecodeError" + } + } + } + } + }, + "description": "my description", + "summary": "my summary", + "deprecated": true, + "externalDocs": { + "url": "http://example.com", + "description": "example" + } + } + } + }, + ... +} +*/ ``` ## Deriving a Client diff --git a/packages/platform/src/HttpApi.ts b/packages/platform/src/HttpApi.ts index d83794cd326..661584254e4 100644 --- a/packages/platform/src/HttpApi.ts +++ b/packages/platform/src/HttpApi.ts @@ -445,6 +445,9 @@ const getDescriptionOrIdentifier = (ast: AST.PropertySignature | AST.AST): Optio } /** + * Adds additional schemas to components/schemas. + * The provided schemas must have a `identifier` annotation. + * * @since 1.0.0 * @category tags */ diff --git a/packages/platform/test/OpenApi.test.ts b/packages/platform/test/OpenApi.test.ts new file mode 100644 index 00000000000..519892b85ac --- /dev/null +++ b/packages/platform/test/OpenApi.test.ts @@ -0,0 +1,907 @@ +import type { OpenApiJsonSchema } from "@effect/platform" +import { + HttpApi, + HttpApiEndpoint, + HttpApiGroup, + HttpApiMiddleware, + HttpApiSchema, + HttpApiSecurity, + Multipart, + OpenApi +} from "@effect/platform" +import { Context, Schema, Struct } from "effect" +import { assert, describe, expect, it } from "vitest" +import OpenApiFixture from "./fixtures/openapi.json" with { type: "json" } + +const HttpApiDecodeError = { + "description": "The request did not match the expected schema", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HttpApiDecodeError" + } + } + } +} + +type Options = { + readonly paths: OpenApi.OpenAPISpec["paths"] + readonly securitySchemes?: Record | undefined + readonly schemas?: Record | undefined + readonly security?: Array | undefined +} + +const getSpec = (options: Options): OpenApi.OpenAPISpec => { + return { + "openapi": "3.1.0", + "info": { "title": "Api", "version": "0.0.1" }, + "paths": options.paths, + "tags": [{ "name": "group" }], + "components": { + "schemas": { + "HttpApiDecodeError": { + "type": "object", + "required": ["issues", "message", "_tag"], + "properties": { + "issues": { + "type": "array", + "items": { + "type": "object", + "required": ["_tag", "path", "message"], + "properties": { + "_tag": { + "type": "string", + "enum": [ + "Pointer", + "Unexpected", + "Missing", + "Composite", + "Refinement", + "Transformation", + "Type", + "Forbidden" + ] + }, + "path": { + "type": "array", + "items": { + "anyOf": [{ "type": "string" }, { "type": "number" }] + } + }, + "message": { "type": "string" } + }, + "additionalProperties": false + } + }, + "message": { "type": "string" }, + "_tag": { + "type": "string", + "enum": [ + "HttpApiDecodeError" + ] + } + }, + "additionalProperties": false, + "description": "The request did not match the expected schema" + }, + ...options.schemas + }, + "securitySchemes": options.securitySchemes ?? {} + }, + "security": options.security ?? [] + } +} + +const expectOptions = (api: HttpApi.HttpApi.Any, options: Options) => { + expectSpec(api, getSpec(options)) +} + +const expectPaths = (api: HttpApi.HttpApi.Any, paths: OpenApi.OpenAPISpec["paths"]) => { + expectSpec(api, getSpec({ paths })) +} + +const expectSpec = (api: HttpApi.HttpApi.Any, expected: OpenApi.OpenAPISpec) => { + const spec = OpenApi.fromApi(api) + // console.log(JSON.stringify(spec.paths, null, 2)) + // console.log(JSON.stringify(spec, null, 2)) + expect(spec).toStrictEqual(expected) +} + +describe("OpenApi", () => { + describe("fromApi", () => { + describe("HttpApi.make", () => { + it("annotations", () => { + const api = HttpApi.make("api") + .annotate(HttpApi.AdditionalSchemas, [ + Schema.String.annotations({ identifier: "MyString" }), + Schema.Number // TODO without an identifier annotation it doesn't appear in the output, correct? + ]) + .annotate(OpenApi.Description, "my description") + .annotate(OpenApi.License, { name: "MIT", url: "http://example.com" }) + .annotate(OpenApi.Summary, "my summary") + .annotate(OpenApi.Servers, [{ + url: "http://example.com", + description: "example", + variables: { a: { default: "b", enum: ["c"], description: "d" } } + }]) + .annotate(OpenApi.Override, { tags: [{ name: "a", description: "a-description" }] }) + .annotate( + OpenApi.Transform, + (spec) => ({ ...spec, tags: [...spec.tags, { "name": "b", "description": "b-description" }] }) + ) + expectSpec(api, { + "openapi": "3.1.0", + "info": { + "title": "Api", + "version": "0.0.1", + "description": "my description", + "license": { + "name": "MIT", + "url": "http://example.com" + }, + "summary": "my summary" + }, + "paths": {}, + "tags": [ + { "name": "a", "description": "a-description" }, + { "name": "b", "description": "b-description" } + ], + "components": { + "schemas": { + "MyString": { + "type": "string" + } + }, + "securitySchemes": {} + }, + "security": [], + "servers": [ + { + "url": "http://example.com", + "description": "example", + "variables": { + "a": { + "default": "b", + "enum": [ + "c" + ], + "description": "d" + } + } + } + ] + }) + }) + }) + + describe("HttpGroup.make", () => { + it("annotations", () => { + const api = HttpApi.make("api") + .add( + HttpApiGroup.make("group") + .annotate(OpenApi.Description, "my description") + .annotate(OpenApi.ExternalDocs, { url: "http://example.com", description: "example" }) + .annotate(OpenApi.Override, { name: "my name" }) + .annotate(OpenApi.Transform, (spec) => ({ ...spec, name: spec.name + "-transformed" })) + ) + .add(HttpApiGroup.make("excluded").annotate(OpenApi.Exclude, true)) + + expectSpec(api, { + "openapi": "3.1.0", + "info": { + "title": "Api", + "version": "0.0.1" + }, + "paths": {}, + "tags": [{ + "name": "my name-transformed", + "description": "my description", + "externalDocs": { + "description": "example", + "url": "http://example.com" + } + }], + "components": { + "schemas": {}, + "securitySchemes": {} + }, + "security": [] + }) + }) + }) + + describe("HttpApiEndpoint.get", () => { + it("/", () => { + const api = HttpApi.make("api").add( + HttpApiGroup.make("group").add( + HttpApiEndpoint.get("get", "/") + .addSuccess(Schema.String) + ) + ) + const expected: OpenApi.OpenAPISpec["paths"] = { + "/": { + "get": { + "tags": ["group"], + "operationId": "group.get", + "parameters": [], + "security": [], + "responses": { + "200": { + "description": "a string", + "content": { + "application/json": { + "schema": { "type": "string" } + } + } + }, + "400": HttpApiDecodeError + } + } + } + } + expectPaths(api, expected) + // should cache the result + expectPaths(api, expected) + }) + + it("+ path param", () => { + const api = HttpApi.make("api").add( + HttpApiGroup.make("group").add( + HttpApiEndpoint.get("get", "/a/:id/b") + .setPath(Schema.Struct({ id: Schema.String })) + .addSuccess(Schema.String) + ) + ) + expectPaths(api, { + "/a/{id}/b": { + "get": { + "tags": ["group"], + "operationId": "group.get", + "parameters": [ + { + "name": "id", + "in": "path", + "schema": { "type": "string" }, + "required": true + } + ], + "security": [], + "responses": { + "200": { + "description": "a string", + "content": { + "application/json": { + "schema": { "type": "string" } + } + } + }, + "400": HttpApiDecodeError + } + } + } + }) + }) + + it("+ url param", () => { + const api = HttpApi.make("api").add( + HttpApiGroup.make("group").add( + HttpApiEndpoint.get("get", "/") + .setUrlParams(Schema.Struct({ id: Schema.String })) + .addSuccess(Schema.String) + ) + ) + expectPaths(api, { + "/": { + "get": { + "tags": ["group"], + "operationId": "group.get", + "parameters": [ + { + "name": "id", + "in": "query", + "schema": { "type": "string" }, + "required": true + } + ], + "security": [], + "responses": { + "200": { + "description": "a string", + "content": { + "application/json": { + "schema": { "type": "string" } + } + } + }, + "400": HttpApiDecodeError + } + } + } + }) + }) + + it("+ error", () => { + const api = HttpApi.make("api").add( + HttpApiGroup.make("group").add( + HttpApiEndpoint.get("get", "/") + .addSuccess(Schema.String) + .addError(Schema.String) + ) + ) + expectPaths(api, { + "/": { + "get": { + "tags": ["group"], + "operationId": "group.get", + "parameters": [], + "security": [], + "responses": { + "200": { + "description": "a string", + "content": { + "application/json": { + "schema": { "type": "string" } + } + } + }, + "400": HttpApiDecodeError, + "500": { + "description": "a string", + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + } + } + } + } + } + }) + }) + + it("error + status annotation", () => { + const api = HttpApi.make("api").add( + HttpApiGroup.make("group").add( + HttpApiEndpoint.get("get", "/") + .addSuccess(Schema.String) + .addError(Schema.String, { status: 404 }) + ) + ) + expectPaths(api, { + "/": { + "get": { + "tags": ["group"], + "operationId": "group.get", + "parameters": [], + "security": [], + "responses": { + "200": { + "description": "a string", + "content": { + "application/json": { + "schema": { "type": "string" } + } + } + }, + "400": HttpApiDecodeError, + "404": { + "description": "a string", + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + } + } + } + } + } + }) + }) + + it("+ annotations", () => { + const api = HttpApi.make("api").add( + HttpApiGroup.make("group").add( + HttpApiEndpoint.get("get", "/") + .addSuccess(Schema.String) + .annotate(OpenApi.Description, "my description") + .annotate(OpenApi.Summary, "my summary") + .annotate(OpenApi.Deprecated, true) + .annotate(OpenApi.ExternalDocs, { url: "http://example.com", description: "example" }) + .annotate(OpenApi.Override, { operationId: "my operationId" }) + .annotate(OpenApi.Transform, (spec) => ({ ...spec, operationId: spec.operationId + "-transformed" })) + ).add( + HttpApiEndpoint.get("excluded", "/excluded") + .addSuccess(Schema.String) + .annotate(OpenApi.Exclude, true) + ) + ) + expectPaths(api, { + "/": { + "get": { + "description": "my description", + "summary": "my summary", + "deprecated": true, + "externalDocs": { "url": "http://example.com", "description": "example" }, + "tags": ["group"], + "operationId": "my operationId-transformed", + "parameters": [], + "security": [], + "responses": { + "200": { + "description": "a string", + "content": { + "application/json": { + "schema": { "type": "string" } + } + } + }, + "400": HttpApiDecodeError + } + } + } + }) + }) + + it("+ Security Middleware", () => { + // Define a schema for the "Unauthorized" error + class Unauthorized extends Schema.TaggedError()( + "Unauthorized", + {}, + // Specify the HTTP status code for unauthorized errors + HttpApiSchema.annotations({ status: 401 }) + ) {} + + class Resource extends Context.Tag("Resource")() {} + + // Create the Authorization middleware + class Authorization extends HttpApiMiddleware.Tag()( + "Authorization", + { + failure: Unauthorized, + provides: Resource, + security: { + myBearer: HttpApiSecurity.bearer + } + } + ) {} + const api = HttpApi.make("api").add( + HttpApiGroup.make("group") + .add( + HttpApiEndpoint.get("get", "/") + .addSuccess(Schema.String) + // Apply the middleware to a single endpoint + .middleware(Authorization) + ) + // Or apply the middleware to the entire group + .middleware(Authorization) + ) + // Or apply the middleware to the entire API + .middleware(Authorization) + expectOptions(api, { + security: [{ "myBearer": [] }], + securitySchemes: { + "myBearer": { + "type": "http", + "scheme": "bearer" + } + }, + schemas: { + "Unauthorized": { + "type": "object", + "required": [ + "_tag" + ], + "properties": { + "_tag": { + "type": "string", + "enum": [ + "Unauthorized" + ] + } + }, + "additionalProperties": false + } + }, + paths: { + "/": { + "get": { + "tags": [ + "group" + ], + "operationId": "group.get", + "parameters": [], + "security": [ + { + "myBearer": [] + } + ], + "responses": { + "200": { + "description": "a string", + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + } + }, + "400": { + "description": "The request did not match the expected schema", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HttpApiDecodeError" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/Unauthorized" // TODO: deduplicate? + }, + { + "$ref": "#/components/schemas/Unauthorized" + }, + { + "$ref": "#/components/schemas/Unauthorized" + } + ] + } + } + } + } + } + } + } + } + }) + }) + }) + + describe("HttpApiEndpoint.post", () => { + it("+ payload", () => { + const api = HttpApi.make("api").add( + HttpApiGroup.make("group").add( + HttpApiEndpoint.post("post", "/") + .addSuccess(Schema.String) + .setPayload(Schema.Number) + ) + ) + expectPaths(api, { + "/": { + "post": { + "tags": ["group"], + "operationId": "group.post", + "parameters": [], + "security": [], + "requestBody": { + "content": { + "application/json": { + "schema": { "type": "number" } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "a string", + "content": { + "application/json": { + "schema": { "type": "string" } + } + } + }, + "400": HttpApiDecodeError + } + } + } + }) + }) + }) + + describe.skip("HttpApiEndpoint.del", () => { + it("/", () => { + const api = HttpApi.make("api").add( + HttpApiGroup.make("group").add( + HttpApiEndpoint.del("del", "/") + .addSuccess(Schema.String) + ) + ) + expectPaths(api, { + "/": { + "delete": { + "tags": ["group"], + "operationId": "group.del", + "parameters": [], + "security": [], + "responses": { + "200": { + "description": "a string", + "content": { + "application/json": { + "schema": { "type": "string" } + } + } + }, + "400": HttpApiDecodeError + } + } + } + }) + }) + + it("+ path param", () => { + const api = HttpApi.make("api").add( + HttpApiGroup.make("group").add( + HttpApiEndpoint.del("del", "/a/:id/b") + .setPath(Schema.Struct({ id: Schema.String })) + .addSuccess(Schema.String) + ) + ) + expectPaths(api, { + "/a/{id}/b": { + "delete": { + "tags": ["group"], + "operationId": "group.del", + "parameters": [ + { + "name": "id", + "in": "path", + "schema": { "type": "string" }, + "required": true + } + ], + "security": [], + "responses": { + "200": { + "description": "a string", + "content": { + "application/json": { + "schema": { "type": "string" } + } + } + }, + "400": HttpApiDecodeError + } + } + } + }) + }) + + it("+ url param", () => { + const api = HttpApi.make("api").add( + HttpApiGroup.make("group").add( + HttpApiEndpoint.del("del", "/") + .setUrlParams(Schema.Struct({ id: Schema.String })) + .addSuccess(Schema.String) + ) + ) + expectPaths(api, { + "/": { + "delete": { + "tags": ["group"], + "operationId": "group.del", + "parameters": [ + { + "name": "id", + "in": "query", + "schema": { "type": "string" }, + "required": true + } + ], + "security": [], + "responses": { + "200": { + "description": "a string", + "content": { + "application/json": { + "schema": { "type": "string" } + } + } + }, + "400": HttpApiDecodeError + } + } + } + }) + }) + }) + + describe.skip("HttpApiEndpoint.patch", () => { + it("+ payload", () => { + const api = HttpApi.make("api").add( + HttpApiGroup.make("group").add( + HttpApiEndpoint.patch("patch", "/") + .addSuccess(Schema.String) + .setPayload(Schema.Number) + ) + ) + expectPaths(api, { + "/": { + "patch": { + "tags": ["group"], + "operationId": "group.patch", + "parameters": [], + "security": [], + "requestBody": { + "content": { + "application/json": { + "schema": { "type": "number" } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "a string", + "content": { + "application/json": { + "schema": { "type": "string" } + } + } + }, + "400": HttpApiDecodeError + } + } + } + }) + }) + }) + }) + + it("OpenAPI spec", () => { + class GlobalError extends Schema.TaggedClass()("GlobalError", {}) {} + class GroupError extends Schema.TaggedClass()("GroupError", {}) {} + class UserError + extends Schema.TaggedClass()("UserError", {}, HttpApiSchema.annotations({ status: 400 })) + {} + class NoStatusError extends Schema.TaggedClass()("NoStatusError", {}) {} + + class User extends Schema.Class("User")({ + id: Schema.Int, + uuid: Schema.optional(Schema.UUID), + name: Schema.String, + createdAt: Schema.DateTimeUtc + }) {} + + class Group extends Schema.Class("Group")({ + id: Schema.Int, + name: Schema.String + }) {} + + class GroupsApi extends HttpApiGroup.make("groups") + .add( + HttpApiEndpoint.get("findById")`/${HttpApiSchema.param("id", Schema.NumberFromString)}` + .addSuccess(Group) + ) + .add( + HttpApiEndpoint.post("create")`/` + .setPayload(Schema.Union( + Schema.Struct(Struct.pick(Group.fields, "name")), + Schema.Struct({ foo: Schema.String }).pipe( + HttpApiSchema.withEncoding({ kind: "UrlParams" }) + ), + HttpApiSchema.Multipart( + Schema.Struct(Struct.pick(Group.fields, "name")) + ) + )) + .addSuccess(Group) + ) + .addError(GroupError.pipe( + HttpApiSchema.asEmpty({ status: 418, decode: () => new GroupError() }) + )) + .prefix("/groups") + {} + + class AnotherApi extends HttpApi.make("another").add(GroupsApi) {} + + class CurrentUser extends Context.Tag("CurrentUser")() {} + + class Authorization extends HttpApiMiddleware.Tag()("Authorization", { + security: { + cookie: HttpApiSecurity.apiKey({ + in: "cookie", + key: "token" + }) + }, + provides: CurrentUser + }) {} + + class UsersApi extends HttpApiGroup.make("users") + .add( + HttpApiEndpoint.get("findById")`/${HttpApiSchema.param("id", Schema.NumberFromString)}` + .addSuccess(User) + ) + .add( + HttpApiEndpoint.post("create")`/` + .setPayload(Schema.Struct(Struct.omit( + User.fields, + "id", + "createdAt" + ))) + .setUrlParams(Schema.Struct({ + id: Schema.NumberFromString + })) + .addSuccess(User) + .addError(UserError) + .addError(UserError) // ensure errors are deduplicated + ) + .add( + HttpApiEndpoint.get("list")`/` + .setHeaders(Schema.Struct({ + page: Schema.NumberFromString.pipe( + Schema.optionalWith({ default: () => 1 }) + ) + })) + .setUrlParams(Schema.Struct({ + query: Schema.optional(Schema.String).annotations({ description: "search query" }) + })) + .addSuccess(Schema.Array(User)) + .addError(NoStatusError) + .annotate(OpenApi.Deprecated, true) + .annotate(OpenApi.Summary, "test summary") + .annotateContext(OpenApi.annotations({ identifier: "listUsers" })) + ) + .add( + HttpApiEndpoint.post("upload")`/upload` + .setPayload(HttpApiSchema.Multipart(Schema.Struct({ + file: Multipart.SingleFileSchema + }))) + .addSuccess(Schema.Struct({ + contentType: Schema.String, + length: Schema.Int + })) + ) + .middleware(Authorization) + .annotateContext(OpenApi.annotations({ title: "Users API" })) + {} + + class TopLevelApi extends HttpApiGroup.make("root", { topLevel: true }) + .add( + HttpApiEndpoint.get("healthz")`/healthz` + .addSuccess(HttpApiSchema.NoContent.annotations({ description: "Empty" })) + ) + {} + + class Api extends HttpApi.make("api") + .addHttpApi(AnotherApi) + .add(UsersApi.prefix("/users")) + .add(TopLevelApi) + .addError(GlobalError, { status: 413 }) + .annotateContext(OpenApi.annotations({ + title: "API", + summary: "test api summary", + transform: (openApiSpec) => ({ + ...openApiSpec, + tags: [...openApiSpec.tags ?? [], { + name: "Tag from OpenApi.Transform annotation" + }] + }) + })) + .annotate( + HttpApi.AdditionalSchemas, + [ + Schema.Struct({ + contentType: Schema.String, + length: Schema.Int + }).annotations({ + identifier: "ComponentsSchema" + }) + ] + ) + {} + + const spec = OpenApi.fromApi(Api) + assert.deepStrictEqual(spec, OpenApiFixture as any) + }) +}) diff --git a/packages/platform/test/fixtures/openapi.json b/packages/platform/test/fixtures/openapi.json new file mode 100644 index 00000000000..b035ac0e6a8 --- /dev/null +++ b/packages/platform/test/fixtures/openapi.json @@ -0,0 +1,647 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "API", + "version": "0.0.1", + "summary": "test api summary" + }, + "paths": { + "/groups/{id}": { + "get": { + "tags": ["groups"], + "operationId": "groups.findById", + "parameters": [ + { + "name": "id", + "in": "path", + "schema": { + "$ref": "#/components/schemas/NumberFromString" + }, + "required": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Group", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Group" + } + } + } + }, + "400": { + "description": "The request did not match the expected schema", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HttpApiDecodeError" + } + } + } + }, + "413": { + "description": "GlobalError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GlobalError" + } + } + } + }, + "418": { + "description": "GroupError" + } + } + } + }, + "/groups": { + "post": { + "tags": ["groups"], + "operationId": "groups.create", + "parameters": [], + "security": [], + "responses": { + "200": { + "description": "Group", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Group" + } + } + } + }, + "400": { + "description": "The request did not match the expected schema", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HttpApiDecodeError" + } + } + } + }, + "413": { + "description": "GlobalError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GlobalError" + } + } + } + }, + "418": { + "description": "GroupError" + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["name"], + "properties": { + "name": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "type": "object", + "required": ["foo"], + "properties": { + "foo": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "multipart/form-data": { + "schema": { + "type": "object", + "required": ["name"], + "properties": { + "name": { + "type": "string" + } + }, + "additionalProperties": false + } + } + }, + "required": true + } + } + }, + "/users/{id}": { + "get": { + "tags": ["Users API"], + "operationId": "users.findById", + "parameters": [ + { + "name": "id", + "in": "path", + "schema": { + "$ref": "#/components/schemas/NumberFromString" + }, + "required": true + } + ], + "security": [ + { + "cookie": [] + } + ], + "responses": { + "200": { + "description": "User", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/User" + } + } + } + }, + "400": { + "description": "The request did not match the expected schema", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HttpApiDecodeError" + } + } + } + }, + "413": { + "description": "GlobalError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GlobalError" + } + } + } + } + } + } + }, + "/users": { + "post": { + "tags": ["Users API"], + "operationId": "users.create", + "parameters": [ + { + "name": "id", + "in": "query", + "schema": { + "$ref": "#/components/schemas/NumberFromString" + }, + "required": true + } + ], + "security": [ + { + "cookie": [] + } + ], + "responses": { + "200": { + "description": "User", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/User" + } + } + } + }, + "400": { + "description": "The request did not match the expected schema", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/HttpApiDecodeError" + }, + { + "$ref": "#/components/schemas/UserError" + } + ] + } + } + } + }, + "413": { + "description": "GlobalError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GlobalError" + } + } + } + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["name"], + "properties": { + "name": { + "type": "string" + }, + "uuid": { + "$ref": "#/components/schemas/UUID" + } + }, + "additionalProperties": false + } + } + }, + "required": true + } + }, + "get": { + "tags": ["Users API"], + "operationId": "listUsers", + "parameters": [ + { + "name": "page", + "in": "header", + "schema": { + "$ref": "#/components/schemas/NumberFromString" + }, + "required": false + }, + { + "description": "search query", + "in": "query", + "name": "query", + "required": false, + "schema": { + "description": "search query", + "type": "string" + } + } + ], + "security": [ + { + "cookie": [] + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/User" + } + } + } + } + }, + "400": { + "description": "The request did not match the expected schema", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HttpApiDecodeError" + } + } + } + }, + "413": { + "description": "GlobalError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GlobalError" + } + } + } + }, + "500": { + "description": "NoStatusError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NoStatusError" + } + } + } + } + }, + "summary": "test summary", + "deprecated": true + } + }, + "/users/upload": { + "post": { + "tags": ["Users API"], + "operationId": "users.upload", + "parameters": [], + "security": [ + { + "cookie": [] + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["contentType", "length"], + "properties": { + "contentType": { + "type": "string" + }, + "length": { + "$ref": "#/components/schemas/Int" + } + }, + "additionalProperties": false + } + } + } + }, + "400": { + "description": "The request did not match the expected schema", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HttpApiDecodeError" + } + } + } + }, + "413": { + "description": "GlobalError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GlobalError" + } + } + } + } + }, + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "required": ["file"], + "properties": { + "file": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PersistedFile" + }, + "title": "itemsCount(1)", + "description": "an array of exactly 1 item(s)", + "minItems": 1, + "maxItems": 1 + } + }, + "additionalProperties": false + } + } + }, + "required": true + } + } + }, + "/healthz": { + "get": { + "tags": ["root"], + "operationId": "healthz", + "parameters": [], + "security": [], + "responses": { + "204": { + "description": "Empty" + }, + "400": { + "description": "The request did not match the expected schema", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HttpApiDecodeError" + } + } + } + }, + "413": { + "description": "GlobalError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GlobalError" + } + } + } + } + } + } + } + }, + "tags": [ + { + "name": "groups" + }, + { + "name": "Users API" + }, + { + "name": "root" + }, + { + "name": "Tag from OpenApi.Transform annotation" + } + ], + "components": { + "schemas": { + "ComponentsSchema": { + "type": "object", + "required": ["contentType", "length"], + "properties": { + "contentType": { + "type": "string" + }, + "length": { + "$ref": "#/components/schemas/Int" + } + }, + "additionalProperties": false + }, + "Group": { + "type": "object", + "required": ["id", "name"], + "properties": { + "id": { + "$ref": "#/components/schemas/Int" + }, + "name": { + "type": "string" + } + }, + "additionalProperties": false + }, + "NumberFromString": { + "type": "string", + "description": "a string that will be parsed into a number" + }, + "PersistedFile": { + "format": "binary", + "type": "string" + }, + "UUID": { + "description": "a Universally Unique Identifier", + "format": "uuid", + "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$", + "type": "string" + }, + "Int": { + "title": "int", + "description": "an integer", + "type": "integer" + }, + "HttpApiDecodeError": { + "type": "object", + "required": ["issues", "message", "_tag"], + "properties": { + "issues": { + "type": "array", + "items": { + "type": "object", + "required": ["_tag", "path", "message"], + "properties": { + "_tag": { + "type": "string", + "enum": [ + "Pointer", + "Unexpected", + "Missing", + "Composite", + "Refinement", + "Transformation", + "Type", + "Forbidden" + ] + }, + "path": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + } + ] + } + }, + "message": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "message": { + "type": "string" + }, + "_tag": { + "type": "string", + "enum": ["HttpApiDecodeError"] + } + }, + "additionalProperties": false, + "description": "The request did not match the expected schema" + }, + "GlobalError": { + "type": "object", + "required": ["_tag"], + "properties": { + "_tag": { + "type": "string", + "enum": ["GlobalError"] + } + }, + "additionalProperties": false + }, + "User": { + "type": "object", + "required": ["id", "name", "createdAt"], + "properties": { + "id": { + "$ref": "#/components/schemas/Int" + }, + "name": { + "type": "string" + }, + "uuid": { + "$ref": "#/components/schemas/UUID" + }, + "createdAt": { + "$ref": "#/components/schemas/DateTimeUtc" + } + }, + "additionalProperties": false + }, + "DateTimeUtc": { + "description": "a string that will be parsed into a DateTime.Utc", + "type": "string" + }, + "UserError": { + "type": "object", + "required": ["_tag"], + "properties": { + "_tag": { + "type": "string", + "enum": ["UserError"] + } + }, + "additionalProperties": false + }, + "NoStatusError": { + "type": "object", + "required": ["_tag"], + "properties": { + "_tag": { + "type": "string", + "enum": ["NoStatusError"] + } + }, + "additionalProperties": false + } + }, + "securitySchemes": { + "cookie": { + "type": "apiKey", + "name": "token", + "in": "cookie" + } + } + }, + "security": [] +} diff --git a/packages/platform/vitest.config.ts b/packages/platform/vitest.config.ts index 0411095f257..015b4d98785 100644 --- a/packages/platform/vitest.config.ts +++ b/packages/platform/vitest.config.ts @@ -1,6 +1,13 @@ import { mergeConfig, type UserConfigExport } from "vitest/config" import shared from "../../vitest.shared.js" -const config: UserConfigExport = {} +const config: UserConfigExport = { + test: { + coverage: { + reporter: ["html"], + include: ["src/OpenApi.ts"] + } + } +} export default mergeConfig(shared, config)