diff --git a/dist/cjs/index.d.ts b/dist/cjs/index.d.ts new file mode 100644 index 0000000..a697dac --- /dev/null +++ b/dist/cjs/index.d.ts @@ -0,0 +1,34 @@ +import { Elysia } from 'elysia'; +import type { ElysiaSwaggerConfig } from './types'; +/** + * Plugin for [elysia](https://github.com/elysiajs/elysia) that auto-generate Swagger page. + * + * @see https://github.com/elysiajs/elysia-swagger + */ +export declare const swagger: ({ provider, scalarVersion, scalarCDN, scalarConfig, documentation, version, excludeStaticFile, path, specPath, exclude, swaggerOptions, theme, autoDarkMode, excludeMethods, excludeTags }?: ElysiaSwaggerConfig) => Elysia<"", { + decorator: {}; + store: {}; + derive: {}; + resolve: {}; +}, { + typebox: {}; + error: {}; +}, { + schema: {}; + standaloneSchema: {}; + macro: {}; + macroFn: {}; + parser: {}; +}, {}, { + derive: {}; + resolve: {}; + schema: {}; + standaloneSchema: {}; +}, { + derive: {}; + resolve: {}; + schema: {}; + standaloneSchema: {}; +}>; +export type { ElysiaSwaggerConfig }; +export default swagger; diff --git a/dist/cjs/index.js b/dist/cjs/index.js new file mode 100644 index 0000000..ad70767 --- /dev/null +++ b/dist/cjs/index.js @@ -0,0 +1,603 @@ +"use strict"; +var __defProp = Object.defineProperty; +var __getOwnPropDesc = Object.getOwnPropertyDescriptor; +var __getOwnPropNames = Object.getOwnPropertyNames; +var __hasOwnProp = Object.prototype.hasOwnProperty; +var __export = (target, all) => { + for (var name in all) + __defProp(target, name, { get: all[name], enumerable: true }); +}; +var __copyProps = (to, from, except, desc) => { + if (from && typeof from === "object" || typeof from === "function") { + for (let key of __getOwnPropNames(from)) + if (!__hasOwnProp.call(to, key) && key !== except) + __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); + } + return to; +}; +var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); + +// src/index.ts +var index_exports = {}; +__export(index_exports, { + default: () => index_default, + swagger: () => swagger +}); +module.exports = __toCommonJS(index_exports); +var import_elysia2 = require("elysia"); + +// src/swagger/index.ts +function isSchemaObject(schema) { + return "type" in schema || "properties" in schema || "items" in schema; +} +function isDateTimeProperty(key, schema) { + return (key === "createdAt" || key === "updatedAt") && "anyOf" in schema && Array.isArray(schema.anyOf); +} +function transformDateProperties(schema) { + if (!isSchemaObject(schema) || typeof schema !== "object" || schema === null) { + return schema; + } + const newSchema = { ...schema }; + Object.entries(newSchema).forEach(([key, value]) => { + if (isSchemaObject(value)) { + if (isDateTimeProperty(key, value)) { + const dateTimeFormat = value.anyOf?.find( + (item) => isSchemaObject(item) && item.format === "date-time" + ); + if (dateTimeFormat) { + const dateTimeSchema = { + type: "string", + format: "date-time", + default: dateTimeFormat.default + }; + newSchema[key] = dateTimeSchema; + } + } else { + newSchema[key] = transformDateProperties(value); + } + } + }); + return newSchema; +} +var SwaggerUIRender = (info, version, theme, stringifiedSwaggerOptions, autoDarkMode) => { + const swaggerOptions = JSON.parse(stringifiedSwaggerOptions); + if (swaggerOptions.components && swaggerOptions.components.schemas) { + swaggerOptions.components.schemas = Object.fromEntries( + Object.entries(swaggerOptions.components.schemas).map(([key, schema]) => [ + key, + transformDateProperties(schema) + ]) + ); + } + const transformedStringifiedSwaggerOptions = JSON.stringify(swaggerOptions); + return ` + + + + + ${info.title} + + + ${autoDarkMode && typeof theme === "string" ? ` + ` : ""} + ${typeof theme === "string" ? `` : ` +`} + + +
+ + + +`; +}; + +// src/scalar/index.ts +var import_themes = require("@scalar/themes"); +var ScalarRender = (info, version, config, cdn) => ` + + + ${info.title} + + + + + + + + +
+ + + + + +`; + +// src/utils.ts +var import_elysia = require("elysia"); + +// node_modules/@sinclair/typebox/build/esm/type/symbols/symbols.mjs +var TransformKind = Symbol.for("TypeBox.Transform"); +var ReadonlyKind = Symbol.for("TypeBox.Readonly"); +var OptionalKind = Symbol.for("TypeBox.Optional"); +var Hint = Symbol.for("TypeBox.Hint"); +var Kind = Symbol.for("TypeBox.Kind"); + +// src/utils.ts +var toOpenAPIPath = (path) => path.split("/").map((x) => { + if (x.startsWith(":")) { + x = x.slice(1, x.length); + if (x.endsWith("?")) x = x.slice(0, -1); + x = `{${x}}`; + } + return x; +}).join("/"); +var mapProperties = (name, schema, models) => { + if (schema === void 0) return []; + if (typeof schema === "string") + if (schema in models) schema = models[schema]; + else throw new Error(`Can't find model ${schema}`); + return Object.entries(schema?.properties ?? []).map(([key, value]) => { + const { + type: valueType = void 0, + description, + examples, + ...schemaKeywords + } = value; + return { + // @ts-ignore + description, + examples, + schema: { type: valueType, ...schemaKeywords }, + in: name, + name: key, + // @ts-ignore + required: schema.required?.includes(key) ?? false + }; + }); +}; +var mapTypesResponse = (types, schema) => { + if (typeof schema === "object" && ["void", "undefined", "null"].includes(schema.type)) + return; + const responses = {}; + for (const type of types) { + responses[type] = { + schema: typeof schema === "string" ? { + $ref: `#/components/schemas/${schema}` + } : "$ref" in schema && Kind in schema && schema[Kind] === "Ref" ? { + ...schema, + $ref: `#/components/schemas/${schema.$ref}` + } : (0, import_elysia.replaceSchemaType)( + { ...schema }, + { + from: import_elysia.t.Ref(""), + // @ts-expect-error + to: ({ $ref, ...options }) => { + if (!$ref.startsWith( + "#/components/schemas/" + )) + return import_elysia.t.Ref( + `#/components/schemas/${$ref}`, + options + ); + return import_elysia.t.Ref($ref, options); + } + } + ) + }; + } + return responses; +}; +var capitalize = (word) => word.charAt(0).toUpperCase() + word.slice(1); +var generateOperationId = (method, paths) => { + let operationId = method.toLowerCase(); + if (paths === "/") return operationId + "Index"; + for (const path of paths.split("/")) { + if (path.charCodeAt(0) === 123) { + operationId += "By" + capitalize(path.slice(1, -1)); + } else { + operationId += capitalize(path); + } + } + return operationId; +}; +var cloneHook = (hook) => { + if (!hook) return; + if (typeof hook === "string") return hook; + if (Array.isArray(hook)) return [...hook]; + return { ...hook }; +}; +var registerSchemaPath = ({ + schema, + path, + method, + hook, + models +}) => { + hook = cloneHook(hook); + if (hook.parse && !Array.isArray(hook.parse)) hook.parse = [hook.parse]; + let contentType = hook.parse?.map((x) => { + switch (typeof x) { + case "string": + return x; + case "object": + if (x && typeof x?.fn !== "string") + return; + switch (x?.fn) { + case "json": + case "application/json": + return "application/json"; + case "text": + case "text/plain": + return "text/plain"; + case "urlencoded": + case "application/x-www-form-urlencoded": + return "application/x-www-form-urlencoded"; + case "arrayBuffer": + case "application/octet-stream": + return "application/octet-stream"; + case "formdata": + case "multipart/form-data": + return "multipart/form-data"; + } + } + }).filter((x) => x !== void 0); + if (!contentType || contentType.length === 0) + contentType = ["application/json", "multipart/form-data", "text/plain"]; + path = toOpenAPIPath(path); + const contentTypes = typeof contentType === "string" ? [contentType] : contentType ?? ["application/json"]; + const bodySchema = cloneHook(hook?.body); + const paramsSchema = cloneHook(hook?.params); + const headerSchema = cloneHook(hook?.headers); + const querySchema = cloneHook(hook?.query); + let responseSchema = cloneHook(hook?.response); + if (typeof responseSchema === "object") { + if (Kind in responseSchema) { + const { + type, + properties, + required, + additionalProperties, + patternProperties, + $ref, + ...rest + } = responseSchema; + responseSchema = { + "200": { + ...rest, + description: rest.description, + content: mapTypesResponse( + contentTypes, + type === "object" || type === "array" ? { + type, + properties, + patternProperties, + items: responseSchema.items, + required + } : responseSchema + ) + } + }; + } else { + Object.entries(responseSchema).forEach( + ([key, value]) => { + if (typeof value === "string") { + if (!models[value]) return; + const { + type, + properties, + required, + additionalProperties: _1, + patternProperties: _2, + ...rest + } = models[value]; + responseSchema[key] = { + ...rest, + description: rest.description, + content: mapTypesResponse(contentTypes, value) + }; + } else { + const { + type, + properties, + required, + additionalProperties, + patternProperties, + ...rest + } = value; + responseSchema[key] = { + ...rest, + description: rest.description, + content: mapTypesResponse( + contentTypes, + type === "object" || type === "array" ? { + type, + properties, + patternProperties, + items: value.items, + required + } : value + ) + }; + } + } + ); + } + } else if (typeof responseSchema === "string") { + if (!(responseSchema in models)) return; + const { + type, + properties, + required, + $ref, + additionalProperties: _1, + patternProperties: _2, + ...rest + } = models[responseSchema]; + responseSchema = { + // @ts-ignore + "200": { + ...rest, + content: mapTypesResponse(contentTypes, responseSchema) + } + }; + } + const parameters = [ + ...mapProperties("header", headerSchema, models), + ...mapProperties("path", paramsSchema, models), + ...mapProperties("query", querySchema, models) + ]; + schema[path] = { + ...schema[path] ? schema[path] : {}, + [method.toLowerCase()]: { + ...headerSchema || paramsSchema || querySchema || bodySchema ? { parameters } : {}, + ...responseSchema ? { + responses: responseSchema + } : {}, + operationId: hook?.detail?.operationId ?? generateOperationId(method, path), + ...hook?.detail, + ...bodySchema ? { + requestBody: { + required: true, + content: mapTypesResponse( + contentTypes, + typeof bodySchema === "string" ? { + $ref: `#/components/schemas/${bodySchema}` + } : bodySchema + ) + } + } : null + } + }; +}; +var filterPaths = (paths, { + excludeStaticFile = true, + exclude = [] +}) => { + const newPaths = {}; + for (const [key, value] of Object.entries(paths)) + if (!exclude.some((x) => { + if (typeof x === "string") return key === x; + return x.test(key); + }) && !key.includes("*") && (excludeStaticFile ? !key.includes(".") : true)) { + Object.keys(value).forEach((method) => { + const schema = value[method]; + if (key.includes("{")) { + if (!schema.parameters) schema.parameters = []; + schema.parameters = [ + ...key.split("/").filter( + (x) => x.startsWith("{") && !schema.parameters.find( + (params) => params.in === "path" && params.name === x.slice(1, x.length - 1) + ) + ).map((x) => ({ + schema: { type: "string" }, + in: "path", + name: x.slice(1, x.length - 1), + required: true + })), + ...schema.parameters + ]; + } + if (!schema.responses) + schema.responses = { + 200: {} + }; + }); + newPaths[key] = value; + } + return newPaths; +}; + +// src/index.ts +var swagger = ({ + provider = "scalar", + scalarVersion = "latest", + scalarCDN = "", + scalarConfig = {}, + documentation = {}, + version = "5.9.0", + excludeStaticFile = true, + path = "/swagger", + specPath = `${path}/json`, + exclude = [], + swaggerOptions = {}, + theme = `https://unpkg.com/swagger-ui-dist@${version}/swagger-ui.css`, + autoDarkMode = true, + excludeMethods = ["OPTIONS"], + excludeTags = [] +} = {}) => { + const schema = {}; + let totalRoutes = 0; + if (!version) + version = `https://unpkg.com/swagger-ui-dist@${version}/swagger-ui.css`; + const info = { + title: "Elysia Documentation", + description: "Development documentation", + version: "0.0.0", + ...documentation.info + }; + const app = new import_elysia2.Elysia({ name: "@elysiajs/swagger" }); + const page = new Response( + provider === "swagger-ui" ? SwaggerUIRender( + info, + version, + theme, + JSON.stringify( + { + url: specPath, + dom_id: "#swagger-ui", + ...swaggerOptions + }, + (_, value) => typeof value === "function" ? void 0 : value + ), + autoDarkMode + ) : ScalarRender( + info, + scalarVersion, + { + sources: [{ url: specPath }], + ...scalarConfig, + // so we can showcase the elysia theme + _integration: "elysiajs" + }, + scalarCDN + ), + { + headers: { + "content-type": "text/html; charset=utf8" + } + } + ); + app.get(path, page, { + detail: { + hide: true + } + }).get( + specPath, + function openAPISchema() { + const routes = app.getGlobalRoutes(); + if (routes.length !== totalRoutes) { + const ALLOWED_METHODS = [ + "GET", + "PUT", + "POST", + "DELETE", + "OPTIONS", + "HEAD", + "PATCH", + "TRACE" + ]; + totalRoutes = routes.length; + routes.forEach((route) => { + if (route.hooks?.detail?.hide === true) return; + if (excludeMethods.includes(route.method)) return; + if (ALLOWED_METHODS.includes(route.method) === false && route.method !== "ALL") + return; + if (route.method === "ALL") + ALLOWED_METHODS.forEach((method) => { + registerSchemaPath({ + schema, + hook: route.hooks, + method, + path: route.path, + // @ts-ignore + models: app.getGlobalDefinitions?.().type, + contentType: route.hooks.type + }); + }); + else + registerSchemaPath({ + schema, + hook: route.hooks, + method: route.method, + path: route.path, + // @ts-ignore + models: app.getGlobalDefinitions?.().type, + contentType: route.hooks.type + }); + }); + } + return { + openapi: "3.0.3", + ...{ + ...documentation, + tags: documentation.tags?.filter( + (tag) => !excludeTags?.includes(tag?.name) + ), + info: { + title: "Elysia Documentation", + description: "Development documentation", + version: "0.0.0", + ...documentation.info + } + }, + paths: { + ...filterPaths(schema, { + excludeStaticFile, + exclude: Array.isArray(exclude) ? exclude : [exclude] + }), + ...documentation.paths + }, + components: { + ...documentation.components, + schemas: { + // @ts-ignore + ...app.getGlobalDefinitions?.().type, + ...documentation.components?.schemas + } + } + }; + }, + { + detail: { + hide: true + } + } + ); + return app; +}; +var index_default = swagger; +// Annotate the CommonJS export names for ESM import in node: +0 && (module.exports = { + swagger +}); diff --git a/dist/cjs/scalar/index.js b/dist/cjs/scalar/index.js new file mode 100644 index 0000000..ede76bc --- /dev/null +++ b/dist/cjs/scalar/index.js @@ -0,0 +1,65 @@ +"use strict"; +var __defProp = Object.defineProperty; +var __getOwnPropDesc = Object.getOwnPropertyDescriptor; +var __getOwnPropNames = Object.getOwnPropertyNames; +var __hasOwnProp = Object.prototype.hasOwnProperty; +var __export = (target, all) => { + for (var name in all) + __defProp(target, name, { get: all[name], enumerable: true }); +}; +var __copyProps = (to, from, except, desc) => { + if (from && typeof from === "object" || typeof from === "function") { + for (let key of __getOwnPropNames(from)) + if (!__hasOwnProp.call(to, key) && key !== except) + __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); + } + return to; +}; +var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); + +// src/scalar/index.ts +var scalar_exports = {}; +__export(scalar_exports, { + ScalarRender: () => ScalarRender +}); +module.exports = __toCommonJS(scalar_exports); +var import_themes = require("@scalar/themes"); +var ScalarRender = (info, version, config, cdn) => ` + + + ${info.title} + + + + + + + + +
+ + + + + +`; +// Annotate the CommonJS export names for ESM import in node: +0 && (module.exports = { + ScalarRender +}); diff --git a/dist/cjs/swagger/index.js b/dist/cjs/swagger/index.js new file mode 100644 index 0000000..f83bd1b --- /dev/null +++ b/dist/cjs/swagger/index.js @@ -0,0 +1,116 @@ +"use strict"; +var __defProp = Object.defineProperty; +var __getOwnPropDesc = Object.getOwnPropertyDescriptor; +var __getOwnPropNames = Object.getOwnPropertyNames; +var __hasOwnProp = Object.prototype.hasOwnProperty; +var __export = (target, all) => { + for (var name in all) + __defProp(target, name, { get: all[name], enumerable: true }); +}; +var __copyProps = (to, from, except, desc) => { + if (from && typeof from === "object" || typeof from === "function") { + for (let key of __getOwnPropNames(from)) + if (!__hasOwnProp.call(to, key) && key !== except) + __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); + } + return to; +}; +var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); + +// src/swagger/index.ts +var swagger_exports = {}; +__export(swagger_exports, { + SwaggerUIRender: () => SwaggerUIRender +}); +module.exports = __toCommonJS(swagger_exports); +function isSchemaObject(schema) { + return "type" in schema || "properties" in schema || "items" in schema; +} +function isDateTimeProperty(key, schema) { + return (key === "createdAt" || key === "updatedAt") && "anyOf" in schema && Array.isArray(schema.anyOf); +} +function transformDateProperties(schema) { + if (!isSchemaObject(schema) || typeof schema !== "object" || schema === null) { + return schema; + } + const newSchema = { ...schema }; + Object.entries(newSchema).forEach(([key, value]) => { + if (isSchemaObject(value)) { + if (isDateTimeProperty(key, value)) { + const dateTimeFormat = value.anyOf?.find( + (item) => isSchemaObject(item) && item.format === "date-time" + ); + if (dateTimeFormat) { + const dateTimeSchema = { + type: "string", + format: "date-time", + default: dateTimeFormat.default + }; + newSchema[key] = dateTimeSchema; + } + } else { + newSchema[key] = transformDateProperties(value); + } + } + }); + return newSchema; +} +var SwaggerUIRender = (info, version, theme, stringifiedSwaggerOptions, autoDarkMode) => { + const swaggerOptions = JSON.parse(stringifiedSwaggerOptions); + if (swaggerOptions.components && swaggerOptions.components.schemas) { + swaggerOptions.components.schemas = Object.fromEntries( + Object.entries(swaggerOptions.components.schemas).map(([key, schema]) => [ + key, + transformDateProperties(schema) + ]) + ); + } + const transformedStringifiedSwaggerOptions = JSON.stringify(swaggerOptions); + return ` + + + + + ${info.title} + + + ${autoDarkMode && typeof theme === "string" ? ` + ` : ""} + ${typeof theme === "string" ? `` : ` +`} + + +
+ + + +`; +}; +// Annotate the CommonJS export names for ESM import in node: +0 && (module.exports = { + SwaggerUIRender +}); diff --git a/dist/cjs/swagger/types.js b/dist/cjs/swagger/types.js new file mode 100644 index 0000000..cbc5109 --- /dev/null +++ b/dist/cjs/swagger/types.js @@ -0,0 +1,18 @@ +"use strict"; +var __defProp = Object.defineProperty; +var __getOwnPropDesc = Object.getOwnPropertyDescriptor; +var __getOwnPropNames = Object.getOwnPropertyNames; +var __hasOwnProp = Object.prototype.hasOwnProperty; +var __copyProps = (to, from, except, desc) => { + if (from && typeof from === "object" || typeof from === "function") { + for (let key of __getOwnPropNames(from)) + if (!__hasOwnProp.call(to, key) && key !== except) + __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); + } + return to; +}; +var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); + +// src/swagger/types.ts +var types_exports = {}; +module.exports = __toCommonJS(types_exports); diff --git a/dist/cjs/types.d.ts b/dist/cjs/types.d.ts new file mode 100644 index 0000000..38da717 --- /dev/null +++ b/dist/cjs/types.d.ts @@ -0,0 +1,102 @@ +import type { OpenAPIV3 } from 'openapi-types'; +import type { ApiReferenceConfigurationWithSources } from '@scalar/types/api-reference' with { "resolution-mode": "import" }; +import type { SwaggerUIOptions } from './swagger/types'; +export interface ElysiaSwaggerConfig { + /** + * Customize Swagger config, refers to Swagger 2.0 config + * + * @see https://swagger.io/specification/v2/ + */ + documentation?: Omit, 'x-express-openapi-additional-middleware' | 'x-express-openapi-validation-strict'>; + /** + * Choose your provider, Scalar or Swagger UI + * + * @default 'scalar' + * @see https://github.com/scalar/scalar + * @see https://github.com/swagger-api/swagger-ui + */ + provider?: 'scalar' | 'swagger-ui'; + /** + * Version to use for Scalar cdn bundle + * + * @default 'latest' + * @see https://github.com/scalar/scalar + */ + scalarVersion?: string; + /** + * Optional override to specifying the path for the Scalar bundle + * + * Custom URL or path to locally hosted Scalar bundle + * + * Lease blank to use default jsdeliver.net CDN + * + * @default '' + * @example 'https://unpkg.com/@scalar/api-reference@1.13.10/dist/browser/standalone.js' + * @example '/public/standalone.js' + * @see https://github.com/scalar/scalar + */ + scalarCDN?: string; + /** + * Scalar configuration to customize scalar + *' + * @see https://github.com/scalar/scalar/blob/main/documentation/configuration.md + */ + scalarConfig?: Partial; + /** + * Version to use for swagger cdn bundle + * + * @see unpkg.com/swagger-ui-dist + * + * @default 4.18.2 + */ + version?: string; + /** + * Determine if Swagger should exclude static files. + * + * @default true + */ + excludeStaticFile?: boolean; + /** + * The endpoint to expose OpenAPI Documentation + * + * @default '/swagger' + */ + path?: Path; + /** + * The endpoint to expose OpenAPI JSON specification + * + * @default '/${path}/json' + */ + specPath?: string; + /** + * Paths to exclude from Swagger endpoint + * + * @default [] + */ + exclude?: string | RegExp | (string | RegExp)[]; + /** + * Options to send to SwaggerUIBundle + * Currently, options that are defined as functions such as requestInterceptor + * and onComplete are not supported. + */ + swaggerOptions?: Omit, 'dom_id' | 'dom_node' | 'spec' | 'url' | 'urls' | 'layout' | 'pluginsOptions' | 'plugins' | 'presets' | 'onComplete' | 'requestInterceptor' | 'responseInterceptor' | 'modelPropertyMacro' | 'parameterMacro'>; + /** + * Custom Swagger CSS + */ + theme?: string | { + light: string; + dark: string; + }; + /** + * Using poor man dark mode 😭 + */ + autoDarkMode?: boolean; + /** + * Exclude methods from Swagger + */ + excludeMethods?: string[]; + /** + * Exclude tags from Swagger or Scalar + */ + excludeTags?: string[]; +} diff --git a/dist/cjs/types.js b/dist/cjs/types.js new file mode 100644 index 0000000..14bbc9e --- /dev/null +++ b/dist/cjs/types.js @@ -0,0 +1,18 @@ +"use strict"; +var __defProp = Object.defineProperty; +var __getOwnPropDesc = Object.getOwnPropertyDescriptor; +var __getOwnPropNames = Object.getOwnPropertyNames; +var __hasOwnProp = Object.prototype.hasOwnProperty; +var __copyProps = (to, from, except, desc) => { + if (from && typeof from === "object" || typeof from === "function") { + for (let key of __getOwnPropNames(from)) + if (!__hasOwnProp.call(to, key) && key !== except) + __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); + } + return to; +}; +var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); + +// src/types.ts +var types_exports = {}; +module.exports = __toCommonJS(types_exports); diff --git a/dist/cjs/utils.d.ts b/dist/cjs/utils.d.ts new file mode 100644 index 0000000..a1d75d3 --- /dev/null +++ b/dist/cjs/utils.d.ts @@ -0,0 +1,26 @@ +import { type HTTPMethod, type LocalHook } from 'elysia'; +import { type TSchema } from '@sinclair/typebox'; +import type { OpenAPIV3 } from 'openapi-types'; +export declare const toOpenAPIPath: (path: string) => string; +export declare const mapProperties: (name: string, schema: TSchema | string | undefined, models: Record) => { + description: any; + examples: any; + schema: any; + in: string; + name: string; + required: any; +}[]; +export declare const capitalize: (word: string) => string; +export declare const generateOperationId: (method: string, paths: string) => string; +export declare const registerSchemaPath: ({ schema, path, method, hook, models }: { + schema: Partial; + contentType?: string | string[]; + path: string; + method: HTTPMethod; + hook?: LocalHook; + models: Record; +}) => void; +export declare const filterPaths: (paths: Record, { excludeStaticFile, exclude }: { + excludeStaticFile: boolean; + exclude: (string | RegExp)[]; +}) => Record; diff --git a/dist/cjs/utils.js b/dist/cjs/utils.js new file mode 100644 index 0000000..5e3d35d --- /dev/null +++ b/dist/cjs/utils.js @@ -0,0 +1,332 @@ +"use strict"; +var __defProp = Object.defineProperty; +var __getOwnPropDesc = Object.getOwnPropertyDescriptor; +var __getOwnPropNames = Object.getOwnPropertyNames; +var __hasOwnProp = Object.prototype.hasOwnProperty; +var __export = (target, all) => { + for (var name in all) + __defProp(target, name, { get: all[name], enumerable: true }); +}; +var __copyProps = (to, from, except, desc) => { + if (from && typeof from === "object" || typeof from === "function") { + for (let key of __getOwnPropNames(from)) + if (!__hasOwnProp.call(to, key) && key !== except) + __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); + } + return to; +}; +var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); + +// src/utils.ts +var utils_exports = {}; +__export(utils_exports, { + capitalize: () => capitalize, + filterPaths: () => filterPaths, + generateOperationId: () => generateOperationId, + mapProperties: () => mapProperties, + registerSchemaPath: () => registerSchemaPath, + toOpenAPIPath: () => toOpenAPIPath +}); +module.exports = __toCommonJS(utils_exports); +var import_elysia = require("elysia"); + +// node_modules/@sinclair/typebox/build/esm/type/symbols/symbols.mjs +var TransformKind = Symbol.for("TypeBox.Transform"); +var ReadonlyKind = Symbol.for("TypeBox.Readonly"); +var OptionalKind = Symbol.for("TypeBox.Optional"); +var Hint = Symbol.for("TypeBox.Hint"); +var Kind = Symbol.for("TypeBox.Kind"); + +// src/utils.ts +var toOpenAPIPath = (path) => path.split("/").map((x) => { + if (x.startsWith(":")) { + x = x.slice(1, x.length); + if (x.endsWith("?")) x = x.slice(0, -1); + x = `{${x}}`; + } + return x; +}).join("/"); +var mapProperties = (name, schema, models) => { + if (schema === void 0) return []; + if (typeof schema === "string") + if (schema in models) schema = models[schema]; + else throw new Error(`Can't find model ${schema}`); + return Object.entries(schema?.properties ?? []).map(([key, value]) => { + const { + type: valueType = void 0, + description, + examples, + ...schemaKeywords + } = value; + return { + // @ts-ignore + description, + examples, + schema: { type: valueType, ...schemaKeywords }, + in: name, + name: key, + // @ts-ignore + required: schema.required?.includes(key) ?? false + }; + }); +}; +var mapTypesResponse = (types, schema) => { + if (typeof schema === "object" && ["void", "undefined", "null"].includes(schema.type)) + return; + const responses = {}; + for (const type of types) { + responses[type] = { + schema: typeof schema === "string" ? { + $ref: `#/components/schemas/${schema}` + } : "$ref" in schema && Kind in schema && schema[Kind] === "Ref" ? { + ...schema, + $ref: `#/components/schemas/${schema.$ref}` + } : (0, import_elysia.replaceSchemaType)( + { ...schema }, + { + from: import_elysia.t.Ref(""), + // @ts-expect-error + to: ({ $ref, ...options }) => { + if (!$ref.startsWith( + "#/components/schemas/" + )) + return import_elysia.t.Ref( + `#/components/schemas/${$ref}`, + options + ); + return import_elysia.t.Ref($ref, options); + } + } + ) + }; + } + return responses; +}; +var capitalize = (word) => word.charAt(0).toUpperCase() + word.slice(1); +var generateOperationId = (method, paths) => { + let operationId = method.toLowerCase(); + if (paths === "/") return operationId + "Index"; + for (const path of paths.split("/")) { + if (path.charCodeAt(0) === 123) { + operationId += "By" + capitalize(path.slice(1, -1)); + } else { + operationId += capitalize(path); + } + } + return operationId; +}; +var cloneHook = (hook) => { + if (!hook) return; + if (typeof hook === "string") return hook; + if (Array.isArray(hook)) return [...hook]; + return { ...hook }; +}; +var registerSchemaPath = ({ + schema, + path, + method, + hook, + models +}) => { + hook = cloneHook(hook); + if (hook.parse && !Array.isArray(hook.parse)) hook.parse = [hook.parse]; + let contentType = hook.parse?.map((x) => { + switch (typeof x) { + case "string": + return x; + case "object": + if (x && typeof x?.fn !== "string") + return; + switch (x?.fn) { + case "json": + case "application/json": + return "application/json"; + case "text": + case "text/plain": + return "text/plain"; + case "urlencoded": + case "application/x-www-form-urlencoded": + return "application/x-www-form-urlencoded"; + case "arrayBuffer": + case "application/octet-stream": + return "application/octet-stream"; + case "formdata": + case "multipart/form-data": + return "multipart/form-data"; + } + } + }).filter((x) => x !== void 0); + if (!contentType || contentType.length === 0) + contentType = ["application/json", "multipart/form-data", "text/plain"]; + path = toOpenAPIPath(path); + const contentTypes = typeof contentType === "string" ? [contentType] : contentType ?? ["application/json"]; + const bodySchema = cloneHook(hook?.body); + const paramsSchema = cloneHook(hook?.params); + const headerSchema = cloneHook(hook?.headers); + const querySchema = cloneHook(hook?.query); + let responseSchema = cloneHook(hook?.response); + if (typeof responseSchema === "object") { + if (Kind in responseSchema) { + const { + type, + properties, + required, + additionalProperties, + patternProperties, + $ref, + ...rest + } = responseSchema; + responseSchema = { + "200": { + ...rest, + description: rest.description, + content: mapTypesResponse( + contentTypes, + type === "object" || type === "array" ? { + type, + properties, + patternProperties, + items: responseSchema.items, + required + } : responseSchema + ) + } + }; + } else { + Object.entries(responseSchema).forEach( + ([key, value]) => { + if (typeof value === "string") { + if (!models[value]) return; + const { + type, + properties, + required, + additionalProperties: _1, + patternProperties: _2, + ...rest + } = models[value]; + responseSchema[key] = { + ...rest, + description: rest.description, + content: mapTypesResponse(contentTypes, value) + }; + } else { + const { + type, + properties, + required, + additionalProperties, + patternProperties, + ...rest + } = value; + responseSchema[key] = { + ...rest, + description: rest.description, + content: mapTypesResponse( + contentTypes, + type === "object" || type === "array" ? { + type, + properties, + patternProperties, + items: value.items, + required + } : value + ) + }; + } + } + ); + } + } else if (typeof responseSchema === "string") { + if (!(responseSchema in models)) return; + const { + type, + properties, + required, + $ref, + additionalProperties: _1, + patternProperties: _2, + ...rest + } = models[responseSchema]; + responseSchema = { + // @ts-ignore + "200": { + ...rest, + content: mapTypesResponse(contentTypes, responseSchema) + } + }; + } + const parameters = [ + ...mapProperties("header", headerSchema, models), + ...mapProperties("path", paramsSchema, models), + ...mapProperties("query", querySchema, models) + ]; + schema[path] = { + ...schema[path] ? schema[path] : {}, + [method.toLowerCase()]: { + ...headerSchema || paramsSchema || querySchema || bodySchema ? { parameters } : {}, + ...responseSchema ? { + responses: responseSchema + } : {}, + operationId: hook?.detail?.operationId ?? generateOperationId(method, path), + ...hook?.detail, + ...bodySchema ? { + requestBody: { + required: true, + content: mapTypesResponse( + contentTypes, + typeof bodySchema === "string" ? { + $ref: `#/components/schemas/${bodySchema}` + } : bodySchema + ) + } + } : null + } + }; +}; +var filterPaths = (paths, { + excludeStaticFile = true, + exclude = [] +}) => { + const newPaths = {}; + for (const [key, value] of Object.entries(paths)) + if (!exclude.some((x) => { + if (typeof x === "string") return key === x; + return x.test(key); + }) && !key.includes("*") && (excludeStaticFile ? !key.includes(".") : true)) { + Object.keys(value).forEach((method) => { + const schema = value[method]; + if (key.includes("{")) { + if (!schema.parameters) schema.parameters = []; + schema.parameters = [ + ...key.split("/").filter( + (x) => x.startsWith("{") && !schema.parameters.find( + (params) => params.in === "path" && params.name === x.slice(1, x.length - 1) + ) + ).map((x) => ({ + schema: { type: "string" }, + in: "path", + name: x.slice(1, x.length - 1), + required: true + })), + ...schema.parameters + ]; + } + if (!schema.responses) + schema.responses = { + 200: {} + }; + }); + newPaths[key] = value; + } + return newPaths; +}; +// Annotate the CommonJS export names for ESM import in node: +0 && (module.exports = { + capitalize, + filterPaths, + generateOperationId, + mapProperties, + registerSchemaPath, + toOpenAPIPath +}); diff --git a/dist/index.d.ts b/dist/index.d.ts new file mode 100644 index 0000000..a697dac --- /dev/null +++ b/dist/index.d.ts @@ -0,0 +1,34 @@ +import { Elysia } from 'elysia'; +import type { ElysiaSwaggerConfig } from './types'; +/** + * Plugin for [elysia](https://github.com/elysiajs/elysia) that auto-generate Swagger page. + * + * @see https://github.com/elysiajs/elysia-swagger + */ +export declare const swagger: ({ provider, scalarVersion, scalarCDN, scalarConfig, documentation, version, excludeStaticFile, path, specPath, exclude, swaggerOptions, theme, autoDarkMode, excludeMethods, excludeTags }?: ElysiaSwaggerConfig) => Elysia<"", { + decorator: {}; + store: {}; + derive: {}; + resolve: {}; +}, { + typebox: {}; + error: {}; +}, { + schema: {}; + standaloneSchema: {}; + macro: {}; + macroFn: {}; + parser: {}; +}, {}, { + derive: {}; + resolve: {}; + schema: {}; + standaloneSchema: {}; +}, { + derive: {}; + resolve: {}; + schema: {}; + standaloneSchema: {}; +}>; +export type { ElysiaSwaggerConfig }; +export default swagger; diff --git a/dist/index.mjs b/dist/index.mjs new file mode 100644 index 0000000..fec69d0 --- /dev/null +++ b/dist/index.mjs @@ -0,0 +1,578 @@ +// src/index.ts +import { Elysia } from "elysia"; + +// src/swagger/index.ts +function isSchemaObject(schema) { + return "type" in schema || "properties" in schema || "items" in schema; +} +function isDateTimeProperty(key, schema) { + return (key === "createdAt" || key === "updatedAt") && "anyOf" in schema && Array.isArray(schema.anyOf); +} +function transformDateProperties(schema) { + if (!isSchemaObject(schema) || typeof schema !== "object" || schema === null) { + return schema; + } + const newSchema = { ...schema }; + Object.entries(newSchema).forEach(([key, value]) => { + if (isSchemaObject(value)) { + if (isDateTimeProperty(key, value)) { + const dateTimeFormat = value.anyOf?.find( + (item) => isSchemaObject(item) && item.format === "date-time" + ); + if (dateTimeFormat) { + const dateTimeSchema = { + type: "string", + format: "date-time", + default: dateTimeFormat.default + }; + newSchema[key] = dateTimeSchema; + } + } else { + newSchema[key] = transformDateProperties(value); + } + } + }); + return newSchema; +} +var SwaggerUIRender = (info, version, theme, stringifiedSwaggerOptions, autoDarkMode) => { + const swaggerOptions = JSON.parse(stringifiedSwaggerOptions); + if (swaggerOptions.components && swaggerOptions.components.schemas) { + swaggerOptions.components.schemas = Object.fromEntries( + Object.entries(swaggerOptions.components.schemas).map(([key, schema]) => [ + key, + transformDateProperties(schema) + ]) + ); + } + const transformedStringifiedSwaggerOptions = JSON.stringify(swaggerOptions); + return ` + + + + + ${info.title} + + + ${autoDarkMode && typeof theme === "string" ? ` + ` : ""} + ${typeof theme === "string" ? `` : ` +`} + + +
+ + + +`; +}; + +// src/scalar/index.ts +import { elysiajsTheme } from "@scalar/themes"; +var ScalarRender = (info, version, config, cdn) => ` + + + ${info.title} + + + + + + + + +
+ + + + + +`; + +// src/utils.ts +import { replaceSchemaType, t } from "elysia"; + +// node_modules/@sinclair/typebox/build/esm/type/symbols/symbols.mjs +var TransformKind = Symbol.for("TypeBox.Transform"); +var ReadonlyKind = Symbol.for("TypeBox.Readonly"); +var OptionalKind = Symbol.for("TypeBox.Optional"); +var Hint = Symbol.for("TypeBox.Hint"); +var Kind = Symbol.for("TypeBox.Kind"); + +// src/utils.ts +var toOpenAPIPath = (path) => path.split("/").map((x) => { + if (x.startsWith(":")) { + x = x.slice(1, x.length); + if (x.endsWith("?")) x = x.slice(0, -1); + x = `{${x}}`; + } + return x; +}).join("/"); +var mapProperties = (name, schema, models) => { + if (schema === void 0) return []; + if (typeof schema === "string") + if (schema in models) schema = models[schema]; + else throw new Error(`Can't find model ${schema}`); + return Object.entries(schema?.properties ?? []).map(([key, value]) => { + const { + type: valueType = void 0, + description, + examples, + ...schemaKeywords + } = value; + return { + // @ts-ignore + description, + examples, + schema: { type: valueType, ...schemaKeywords }, + in: name, + name: key, + // @ts-ignore + required: schema.required?.includes(key) ?? false + }; + }); +}; +var mapTypesResponse = (types, schema) => { + if (typeof schema === "object" && ["void", "undefined", "null"].includes(schema.type)) + return; + const responses = {}; + for (const type of types) { + responses[type] = { + schema: typeof schema === "string" ? { + $ref: `#/components/schemas/${schema}` + } : "$ref" in schema && Kind in schema && schema[Kind] === "Ref" ? { + ...schema, + $ref: `#/components/schemas/${schema.$ref}` + } : replaceSchemaType( + { ...schema }, + { + from: t.Ref(""), + // @ts-expect-error + to: ({ $ref, ...options }) => { + if (!$ref.startsWith( + "#/components/schemas/" + )) + return t.Ref( + `#/components/schemas/${$ref}`, + options + ); + return t.Ref($ref, options); + } + } + ) + }; + } + return responses; +}; +var capitalize = (word) => word.charAt(0).toUpperCase() + word.slice(1); +var generateOperationId = (method, paths) => { + let operationId = method.toLowerCase(); + if (paths === "/") return operationId + "Index"; + for (const path of paths.split("/")) { + if (path.charCodeAt(0) === 123) { + operationId += "By" + capitalize(path.slice(1, -1)); + } else { + operationId += capitalize(path); + } + } + return operationId; +}; +var cloneHook = (hook) => { + if (!hook) return; + if (typeof hook === "string") return hook; + if (Array.isArray(hook)) return [...hook]; + return { ...hook }; +}; +var registerSchemaPath = ({ + schema, + path, + method, + hook, + models +}) => { + hook = cloneHook(hook); + if (hook.parse && !Array.isArray(hook.parse)) hook.parse = [hook.parse]; + let contentType = hook.parse?.map((x) => { + switch (typeof x) { + case "string": + return x; + case "object": + if (x && typeof x?.fn !== "string") + return; + switch (x?.fn) { + case "json": + case "application/json": + return "application/json"; + case "text": + case "text/plain": + return "text/plain"; + case "urlencoded": + case "application/x-www-form-urlencoded": + return "application/x-www-form-urlencoded"; + case "arrayBuffer": + case "application/octet-stream": + return "application/octet-stream"; + case "formdata": + case "multipart/form-data": + return "multipart/form-data"; + } + } + }).filter((x) => x !== void 0); + if (!contentType || contentType.length === 0) + contentType = ["application/json", "multipart/form-data", "text/plain"]; + path = toOpenAPIPath(path); + const contentTypes = typeof contentType === "string" ? [contentType] : contentType ?? ["application/json"]; + const bodySchema = cloneHook(hook?.body); + const paramsSchema = cloneHook(hook?.params); + const headerSchema = cloneHook(hook?.headers); + const querySchema = cloneHook(hook?.query); + let responseSchema = cloneHook(hook?.response); + if (typeof responseSchema === "object") { + if (Kind in responseSchema) { + const { + type, + properties, + required, + additionalProperties, + patternProperties, + $ref, + ...rest + } = responseSchema; + responseSchema = { + "200": { + ...rest, + description: rest.description, + content: mapTypesResponse( + contentTypes, + type === "object" || type === "array" ? { + type, + properties, + patternProperties, + items: responseSchema.items, + required + } : responseSchema + ) + } + }; + } else { + Object.entries(responseSchema).forEach( + ([key, value]) => { + if (typeof value === "string") { + if (!models[value]) return; + const { + type, + properties, + required, + additionalProperties: _1, + patternProperties: _2, + ...rest + } = models[value]; + responseSchema[key] = { + ...rest, + description: rest.description, + content: mapTypesResponse(contentTypes, value) + }; + } else { + const { + type, + properties, + required, + additionalProperties, + patternProperties, + ...rest + } = value; + responseSchema[key] = { + ...rest, + description: rest.description, + content: mapTypesResponse( + contentTypes, + type === "object" || type === "array" ? { + type, + properties, + patternProperties, + items: value.items, + required + } : value + ) + }; + } + } + ); + } + } else if (typeof responseSchema === "string") { + if (!(responseSchema in models)) return; + const { + type, + properties, + required, + $ref, + additionalProperties: _1, + patternProperties: _2, + ...rest + } = models[responseSchema]; + responseSchema = { + // @ts-ignore + "200": { + ...rest, + content: mapTypesResponse(contentTypes, responseSchema) + } + }; + } + const parameters = [ + ...mapProperties("header", headerSchema, models), + ...mapProperties("path", paramsSchema, models), + ...mapProperties("query", querySchema, models) + ]; + schema[path] = { + ...schema[path] ? schema[path] : {}, + [method.toLowerCase()]: { + ...headerSchema || paramsSchema || querySchema || bodySchema ? { parameters } : {}, + ...responseSchema ? { + responses: responseSchema + } : {}, + operationId: hook?.detail?.operationId ?? generateOperationId(method, path), + ...hook?.detail, + ...bodySchema ? { + requestBody: { + required: true, + content: mapTypesResponse( + contentTypes, + typeof bodySchema === "string" ? { + $ref: `#/components/schemas/${bodySchema}` + } : bodySchema + ) + } + } : null + } + }; +}; +var filterPaths = (paths, { + excludeStaticFile = true, + exclude = [] +}) => { + const newPaths = {}; + for (const [key, value] of Object.entries(paths)) + if (!exclude.some((x) => { + if (typeof x === "string") return key === x; + return x.test(key); + }) && !key.includes("*") && (excludeStaticFile ? !key.includes(".") : true)) { + Object.keys(value).forEach((method) => { + const schema = value[method]; + if (key.includes("{")) { + if (!schema.parameters) schema.parameters = []; + schema.parameters = [ + ...key.split("/").filter( + (x) => x.startsWith("{") && !schema.parameters.find( + (params) => params.in === "path" && params.name === x.slice(1, x.length - 1) + ) + ).map((x) => ({ + schema: { type: "string" }, + in: "path", + name: x.slice(1, x.length - 1), + required: true + })), + ...schema.parameters + ]; + } + if (!schema.responses) + schema.responses = { + 200: {} + }; + }); + newPaths[key] = value; + } + return newPaths; +}; + +// src/index.ts +var swagger = ({ + provider = "scalar", + scalarVersion = "latest", + scalarCDN = "", + scalarConfig = {}, + documentation = {}, + version = "5.9.0", + excludeStaticFile = true, + path = "/swagger", + specPath = `${path}/json`, + exclude = [], + swaggerOptions = {}, + theme = `https://unpkg.com/swagger-ui-dist@${version}/swagger-ui.css`, + autoDarkMode = true, + excludeMethods = ["OPTIONS"], + excludeTags = [] +} = {}) => { + const schema = {}; + let totalRoutes = 0; + if (!version) + version = `https://unpkg.com/swagger-ui-dist@${version}/swagger-ui.css`; + const info = { + title: "Elysia Documentation", + description: "Development documentation", + version: "0.0.0", + ...documentation.info + }; + const app = new Elysia({ name: "@elysiajs/swagger" }); + const page = new Response( + provider === "swagger-ui" ? SwaggerUIRender( + info, + version, + theme, + JSON.stringify( + { + url: specPath, + dom_id: "#swagger-ui", + ...swaggerOptions + }, + (_, value) => typeof value === "function" ? void 0 : value + ), + autoDarkMode + ) : ScalarRender( + info, + scalarVersion, + { + sources: [{ url: specPath }], + ...scalarConfig, + // so we can showcase the elysia theme + _integration: "elysiajs" + }, + scalarCDN + ), + { + headers: { + "content-type": "text/html; charset=utf8" + } + } + ); + app.get(path, page, { + detail: { + hide: true + } + }).get( + specPath, + function openAPISchema() { + const routes = app.getGlobalRoutes(); + if (routes.length !== totalRoutes) { + const ALLOWED_METHODS = [ + "GET", + "PUT", + "POST", + "DELETE", + "OPTIONS", + "HEAD", + "PATCH", + "TRACE" + ]; + totalRoutes = routes.length; + routes.forEach((route) => { + if (route.hooks?.detail?.hide === true) return; + if (excludeMethods.includes(route.method)) return; + if (ALLOWED_METHODS.includes(route.method) === false && route.method !== "ALL") + return; + if (route.method === "ALL") + ALLOWED_METHODS.forEach((method) => { + registerSchemaPath({ + schema, + hook: route.hooks, + method, + path: route.path, + // @ts-ignore + models: app.getGlobalDefinitions?.().type, + contentType: route.hooks.type + }); + }); + else + registerSchemaPath({ + schema, + hook: route.hooks, + method: route.method, + path: route.path, + // @ts-ignore + models: app.getGlobalDefinitions?.().type, + contentType: route.hooks.type + }); + }); + } + return { + openapi: "3.0.3", + ...{ + ...documentation, + tags: documentation.tags?.filter( + (tag) => !excludeTags?.includes(tag?.name) + ), + info: { + title: "Elysia Documentation", + description: "Development documentation", + version: "0.0.0", + ...documentation.info + } + }, + paths: { + ...filterPaths(schema, { + excludeStaticFile, + exclude: Array.isArray(exclude) ? exclude : [exclude] + }), + ...documentation.paths + }, + components: { + ...documentation.components, + schemas: { + // @ts-ignore + ...app.getGlobalDefinitions?.().type, + ...documentation.components?.schemas + } + } + }; + }, + { + detail: { + hide: true + } + } + ); + return app; +}; +var index_default = swagger; +export { + index_default as default, + swagger +}; diff --git a/dist/scalar/index.d.ts b/dist/scalar/index.d.ts new file mode 100644 index 0000000..22f4bdf --- /dev/null +++ b/dist/scalar/index.d.ts @@ -0,0 +1,3 @@ +import type { OpenAPIV3 } from 'openapi-types'; +import type { ApiReferenceConfigurationWithSources } from '@scalar/types/api-reference' with { "resolution-mode": "import" }; +export declare const ScalarRender: (info: OpenAPIV3.InfoObject, version: string, config: Partial, cdn: string) => string; diff --git a/dist/scalar/index.mjs b/dist/scalar/index.mjs new file mode 100644 index 0000000..aa50f0b --- /dev/null +++ b/dist/scalar/index.mjs @@ -0,0 +1,40 @@ +// src/scalar/index.ts +import { elysiajsTheme } from "@scalar/themes"; +var ScalarRender = (info, version, config, cdn) => ` + + + ${info.title} + + + + + + + + +
+ + + + + +`; +export { + ScalarRender +}; diff --git a/dist/swagger/index.d.ts b/dist/swagger/index.d.ts new file mode 100644 index 0000000..24a0908 --- /dev/null +++ b/dist/swagger/index.d.ts @@ -0,0 +1,5 @@ +import { OpenAPIV3 } from 'openapi-types'; +export declare const SwaggerUIRender: (info: OpenAPIV3.InfoObject, version: string, theme: string | { + light: string; + dark: string; +}, stringifiedSwaggerOptions: string, autoDarkMode?: boolean) => string; diff --git a/dist/swagger/index.mjs b/dist/swagger/index.mjs new file mode 100644 index 0000000..6672429 --- /dev/null +++ b/dist/swagger/index.mjs @@ -0,0 +1,91 @@ +// src/swagger/index.ts +function isSchemaObject(schema) { + return "type" in schema || "properties" in schema || "items" in schema; +} +function isDateTimeProperty(key, schema) { + return (key === "createdAt" || key === "updatedAt") && "anyOf" in schema && Array.isArray(schema.anyOf); +} +function transformDateProperties(schema) { + if (!isSchemaObject(schema) || typeof schema !== "object" || schema === null) { + return schema; + } + const newSchema = { ...schema }; + Object.entries(newSchema).forEach(([key, value]) => { + if (isSchemaObject(value)) { + if (isDateTimeProperty(key, value)) { + const dateTimeFormat = value.anyOf?.find( + (item) => isSchemaObject(item) && item.format === "date-time" + ); + if (dateTimeFormat) { + const dateTimeSchema = { + type: "string", + format: "date-time", + default: dateTimeFormat.default + }; + newSchema[key] = dateTimeSchema; + } + } else { + newSchema[key] = transformDateProperties(value); + } + } + }); + return newSchema; +} +var SwaggerUIRender = (info, version, theme, stringifiedSwaggerOptions, autoDarkMode) => { + const swaggerOptions = JSON.parse(stringifiedSwaggerOptions); + if (swaggerOptions.components && swaggerOptions.components.schemas) { + swaggerOptions.components.schemas = Object.fromEntries( + Object.entries(swaggerOptions.components.schemas).map(([key, schema]) => [ + key, + transformDateProperties(schema) + ]) + ); + } + const transformedStringifiedSwaggerOptions = JSON.stringify(swaggerOptions); + return ` + + + + + ${info.title} + + + ${autoDarkMode && typeof theme === "string" ? ` + ` : ""} + ${typeof theme === "string" ? `` : ` +`} + + +
+ + + +`; +}; +export { + SwaggerUIRender +}; diff --git a/dist/swagger/types.d.ts b/dist/swagger/types.d.ts new file mode 100644 index 0000000..a82f415 --- /dev/null +++ b/dist/swagger/types.d.ts @@ -0,0 +1,274 @@ +/** + * Swagger UI type because swagger-ui doesn't export an interface so here's copy paste + * + * @see swagger-ui/index.d.ts + **/ +export interface SwaggerUIOptions { + /** + * URL to fetch external configuration document from. + */ + configUrl?: string | undefined; + /** + * REQUIRED if domNode is not provided. The ID of a DOM element inside which SwaggerUI will put its user interface. + */ + dom_id?: string | undefined; + /** + * A JavaScript object describing the OpenAPI definition. When used, the url parameter will not be parsed. This is useful for testing manually-generated definitions without hosting them + */ + spec?: { + [propName: string]: any; + } | undefined; + /** + * The URL pointing to API definition (normally swagger.json or swagger.yaml). Will be ignored if urls or spec is used. + */ + url?: string | undefined; + /** + * An array of API definition objects ([{url: "", name: ""},{url: "", name: ""}]) + * used by Topbar plugin. When used and Topbar plugin is enabled, the url parameter will not be parsed. + * Names and URLs must be unique among all items in this array, since they're used as identifiers. + */ + urls?: Array<{ + url: string; + name: string; + }> | undefined; + /** + * The name of a component available via the plugin system to use as the top-level layout + * for Swagger UI. + */ + layout?: string | undefined; + /** + * A Javascript object to configure plugin integration and behaviors + */ + pluginsOptions?: PluginsOptions; + /** + * An array of plugin functions to use in Swagger UI. + */ + plugins?: SwaggerUIPlugin[] | undefined; + /** + * An array of presets to use in Swagger UI. + * Usually, you'll want to include ApisPreset if you use this option. + */ + presets?: SwaggerUIPlugin[] | undefined; + /** + * If set to true, enables deep linking for tags and operations. + * See the Deep Linking documentation for more information. + */ + deepLinking?: boolean | undefined; + /** + * Controls the display of operationId in operations list. The default is false. + */ + displayOperationId?: boolean | undefined; + /** + * The default expansion depth for models (set to -1 completely hide the models). + */ + defaultModelsExpandDepth?: number | undefined; + /** + * The default expansion depth for the model on the model-example section. + */ + defaultModelExpandDepth?: number | undefined; + /** + * Controls how the model is shown when the API is first rendered. + * (The user can always switch the rendering for a given model by clicking the + * 'Model' and 'Example Value' links.) + */ + defaultModelRendering?: 'example' | 'model' | undefined; + /** + * Controls the display of the request duration (in milliseconds) for "Try it out" requests. + */ + displayRequestDuration?: boolean | undefined; + /** + * Controls the default expansion setting for the operations and tags. + * It can be 'list' (expands only the tags), 'full' (expands the tags and operations) + * or 'none' (expands nothing). + */ + docExpansion?: 'list' | 'full' | 'none' | undefined; + /** + * If set, enables filtering. + * The top bar will show an edit box that you can use to filter the tagged operations that are shown. + * Can be Boolean to enable or disable, or a string, in which case filtering will be enabled + * using that string as the filter expression. + * Filtering is case sensitive matching the filter expression anywhere inside the tag. + */ + filter?: boolean | string | undefined; + /** + * If set, limits the number of tagged operations displayed to at most this many. + * The default is to show all operations. + */ + maxDisplayedTags?: number | undefined; + /** + * Apply a sort to the operation list of each API. + * It can be 'alpha' (sort by paths alphanumerically), + * 'method' (sort by HTTP method) or a function (see Array.prototype.sort() to know how sort function works). + * Default is the order returned by the server unchanged. + */ + operationsSorter?: SorterLike | undefined; + /** + * Controls the display of vendor extension (x-) fields and values for Operations, + * Parameters, Responses, and Schema. + */ + showExtensions?: boolean | undefined; + /** + * Controls the display of extensions (pattern, maxLength, minLength, maximum, minimum) fields + * and values for Parameters. + */ + showCommonExtensions?: boolean | undefined; + /** + * Apply a sort to the tag list of each API. + * It can be 'alpha' (sort by paths alphanumerically) + * or a function (see Array.prototype.sort() to learn how to write a sort function). + * Two tag name strings are passed to the sorter for each pass. + * Default is the order determined by Swagger UI. + */ + tagsSorter?: SorterLike | undefined; + /** + * When enabled, sanitizer will leave style, class and data-* attributes untouched + * on all HTML Elements declared inside markdown strings. + * This parameter is Deprecated and will be removed in 4.0.0. + * @deprecated + */ + useUnsafeMarkdown?: boolean | undefined; + /** + * Provides a mechanism to be notified when Swagger UI has finished rendering a newly provided definition. + */ + onComplete?: (() => any) | undefined; + /** + * Set to false to deactivate syntax highlighting of payloads and cURL command, + * can be otherwise an object with the activate and theme properties. + */ + syntaxHighlight?: false | { + /** + * Whether syntax highlighting should be activated or not. + */ + activate?: boolean | undefined; + /** + * Highlight.js syntax coloring theme to use. (Only these 6 styles are available.) + */ + theme?: 'agate' | 'arta' | 'idea' | 'monokai' | 'nord' | 'obsidian' | 'tomorrow-night' | undefined; + } | undefined; + /** + * Controls whether the "Try it out" section should be enabled by default. + */ + tryItOutEnabled?: boolean | undefined; + /** + * This is the default configuration section for the the requestSnippets plugin. + */ + requestSnippets?: { + generators?: { + [genName: string]: { + title: string; + syntax: string; + }; + } | undefined; + defaultExpanded?: boolean | undefined; + /** + * e.g. only show curl bash = ["curl_bash"] + */ + languagesMask?: string[] | undefined; + } | undefined; + /** + * OAuth redirect URL. + */ + oauth2RedirectUrl?: string | undefined; + /** + * MUST be a function. Function to intercept remote definition, + * "Try it out", and OAuth 2.0 requests. + * Accepts one argument requestInterceptor(request) and must return the modified request, + * or a Promise that resolves to the modified request. + */ + requestInterceptor?: ((a: Request) => Request | Promise) | undefined; + /** + * MUST be a function. Function to intercept remote definition, + * "Try it out", and OAuth 2.0 responses. + * Accepts one argument responseInterceptor(response) and must return the modified response, + * or a Promise that resolves to the modified response. + */ + responseInterceptor?: ((a: Response) => Response | Promise) | undefined; + /** + * If set to true, uses the mutated request returned from a requestInterceptor + * to produce the curl command in the UI, otherwise the request + * beforethe requestInterceptor was applied is used. + */ + showMutatedRequest?: boolean | undefined; + /** + * List of HTTP methods that have the "Try it out" feature enabled. + * An empty array disables "Try it out" for all operations. + * This does not filter the operations from the display. + */ + supportedSubmitMethods?: SupportedHTTPMethods[] | undefined; + /** + * By default, Swagger UI attempts to validate specs against swagger.io's online validator. + * You can use this parameter to set a different validator URL, + * for example for locally deployed validators (Validator Badge). + * Setting it to either none, 127.0.0.1 or localhost will disable validation. + */ + validatorUrl?: string | undefined; + /** + * If set to true, enables passing credentials, as defined in the Fetch standard, + * in CORS requests that are sent by the browser. + * Note that Swagger UI cannot currently set cookies cross-domain (see swagger-js#1163) + * - as a result, you will have to rely on browser-supplied + * cookies (which this setting enables sending) that Swagger UI cannot control. + */ + withCredentials?: boolean | undefined; + /** + * Function to set default values to each property in model. + * Accepts one argument modelPropertyMacro(property), property is immutable + */ + modelPropertyMacro?: ((propName: Readonly) => any) | undefined; + /** + * Function to set default value to parameters. + * Accepts two arguments parameterMacro(operation, parameter). + * Operation and parameter are objects passed for context, both remain immutable + */ + parameterMacro?: ((operation: Readonly, parameter: Readonly) => any) | undefined; + /** + * If set to true, it persists authorization data and it would not be lost on browser close/refresh + */ + persistAuthorization?: boolean | undefined; +} +interface PluginsOptions { + /** + * Control behavior of plugins when targeting the same component with wrapComponent.
+ * - `legacy` (default) : last plugin takes precedence over the others
+ * - `chain` : chain wrapComponents when targeting the same core component, + * allowing multiple plugins to wrap the same component + * @default 'legacy' + */ + pluginLoadType?: PluginLoadType; +} +type PluginLoadType = 'legacy' | 'chain'; +type SupportedHTTPMethods = 'get' | 'put' | 'post' | 'delete' | 'options' | 'head' | 'patch' | 'trace'; +type SorterLike = 'alpha' | 'method' | { + (name1: string, name2: string): number; +}; +interface Request { + [prop: string]: any; +} +interface Response { + [prop: string]: any; +} +/** + * See https://swagger.io/docs/open-source-tools/swagger-ui/customization/plugin-api/ + */ +interface SwaggerUIPlugin { + (system: any): { + statePlugins?: { + [stateKey: string]: { + actions?: Indexable | undefined; + reducers?: Indexable | undefined; + selectors?: Indexable | undefined; + wrapActions?: Indexable | undefined; + wrapSelectors?: Indexable | undefined; + }; + } | undefined; + components?: Indexable | undefined; + wrapComponents?: Indexable | undefined; + rootInjects?: Indexable | undefined; + afterLoad?: ((system: any) => any) | undefined; + fn?: Indexable | undefined; + }; +} +interface Indexable { + [index: string]: any; +} +export {}; diff --git a/dist/swagger/types.mjs b/dist/swagger/types.mjs new file mode 100644 index 0000000..e69de29 diff --git a/dist/types.d.ts b/dist/types.d.ts new file mode 100644 index 0000000..38da717 --- /dev/null +++ b/dist/types.d.ts @@ -0,0 +1,102 @@ +import type { OpenAPIV3 } from 'openapi-types'; +import type { ApiReferenceConfigurationWithSources } from '@scalar/types/api-reference' with { "resolution-mode": "import" }; +import type { SwaggerUIOptions } from './swagger/types'; +export interface ElysiaSwaggerConfig { + /** + * Customize Swagger config, refers to Swagger 2.0 config + * + * @see https://swagger.io/specification/v2/ + */ + documentation?: Omit, 'x-express-openapi-additional-middleware' | 'x-express-openapi-validation-strict'>; + /** + * Choose your provider, Scalar or Swagger UI + * + * @default 'scalar' + * @see https://github.com/scalar/scalar + * @see https://github.com/swagger-api/swagger-ui + */ + provider?: 'scalar' | 'swagger-ui'; + /** + * Version to use for Scalar cdn bundle + * + * @default 'latest' + * @see https://github.com/scalar/scalar + */ + scalarVersion?: string; + /** + * Optional override to specifying the path for the Scalar bundle + * + * Custom URL or path to locally hosted Scalar bundle + * + * Lease blank to use default jsdeliver.net CDN + * + * @default '' + * @example 'https://unpkg.com/@scalar/api-reference@1.13.10/dist/browser/standalone.js' + * @example '/public/standalone.js' + * @see https://github.com/scalar/scalar + */ + scalarCDN?: string; + /** + * Scalar configuration to customize scalar + *' + * @see https://github.com/scalar/scalar/blob/main/documentation/configuration.md + */ + scalarConfig?: Partial; + /** + * Version to use for swagger cdn bundle + * + * @see unpkg.com/swagger-ui-dist + * + * @default 4.18.2 + */ + version?: string; + /** + * Determine if Swagger should exclude static files. + * + * @default true + */ + excludeStaticFile?: boolean; + /** + * The endpoint to expose OpenAPI Documentation + * + * @default '/swagger' + */ + path?: Path; + /** + * The endpoint to expose OpenAPI JSON specification + * + * @default '/${path}/json' + */ + specPath?: string; + /** + * Paths to exclude from Swagger endpoint + * + * @default [] + */ + exclude?: string | RegExp | (string | RegExp)[]; + /** + * Options to send to SwaggerUIBundle + * Currently, options that are defined as functions such as requestInterceptor + * and onComplete are not supported. + */ + swaggerOptions?: Omit, 'dom_id' | 'dom_node' | 'spec' | 'url' | 'urls' | 'layout' | 'pluginsOptions' | 'plugins' | 'presets' | 'onComplete' | 'requestInterceptor' | 'responseInterceptor' | 'modelPropertyMacro' | 'parameterMacro'>; + /** + * Custom Swagger CSS + */ + theme?: string | { + light: string; + dark: string; + }; + /** + * Using poor man dark mode 😭 + */ + autoDarkMode?: boolean; + /** + * Exclude methods from Swagger + */ + excludeMethods?: string[]; + /** + * Exclude tags from Swagger or Scalar + */ + excludeTags?: string[]; +} diff --git a/dist/types.mjs b/dist/types.mjs new file mode 100644 index 0000000..e69de29 diff --git a/dist/utils.d.ts b/dist/utils.d.ts new file mode 100644 index 0000000..a1d75d3 --- /dev/null +++ b/dist/utils.d.ts @@ -0,0 +1,26 @@ +import { type HTTPMethod, type LocalHook } from 'elysia'; +import { type TSchema } from '@sinclair/typebox'; +import type { OpenAPIV3 } from 'openapi-types'; +export declare const toOpenAPIPath: (path: string) => string; +export declare const mapProperties: (name: string, schema: TSchema | string | undefined, models: Record) => { + description: any; + examples: any; + schema: any; + in: string; + name: string; + required: any; +}[]; +export declare const capitalize: (word: string) => string; +export declare const generateOperationId: (method: string, paths: string) => string; +export declare const registerSchemaPath: ({ schema, path, method, hook, models }: { + schema: Partial; + contentType?: string | string[]; + path: string; + method: HTTPMethod; + hook?: LocalHook; + models: Record; +}) => void; +export declare const filterPaths: (paths: Record, { excludeStaticFile, exclude }: { + excludeStaticFile: boolean; + exclude: (string | RegExp)[]; +}) => Record; diff --git a/dist/utils.mjs b/dist/utils.mjs new file mode 100644 index 0000000..98ddd80 --- /dev/null +++ b/dist/utils.mjs @@ -0,0 +1,302 @@ +// src/utils.ts +import { replaceSchemaType, t } from "elysia"; + +// node_modules/@sinclair/typebox/build/esm/type/symbols/symbols.mjs +var TransformKind = Symbol.for("TypeBox.Transform"); +var ReadonlyKind = Symbol.for("TypeBox.Readonly"); +var OptionalKind = Symbol.for("TypeBox.Optional"); +var Hint = Symbol.for("TypeBox.Hint"); +var Kind = Symbol.for("TypeBox.Kind"); + +// src/utils.ts +var toOpenAPIPath = (path) => path.split("/").map((x) => { + if (x.startsWith(":")) { + x = x.slice(1, x.length); + if (x.endsWith("?")) x = x.slice(0, -1); + x = `{${x}}`; + } + return x; +}).join("/"); +var mapProperties = (name, schema, models) => { + if (schema === void 0) return []; + if (typeof schema === "string") + if (schema in models) schema = models[schema]; + else throw new Error(`Can't find model ${schema}`); + return Object.entries(schema?.properties ?? []).map(([key, value]) => { + const { + type: valueType = void 0, + description, + examples, + ...schemaKeywords + } = value; + return { + // @ts-ignore + description, + examples, + schema: { type: valueType, ...schemaKeywords }, + in: name, + name: key, + // @ts-ignore + required: schema.required?.includes(key) ?? false + }; + }); +}; +var mapTypesResponse = (types, schema) => { + if (typeof schema === "object" && ["void", "undefined", "null"].includes(schema.type)) + return; + const responses = {}; + for (const type of types) { + responses[type] = { + schema: typeof schema === "string" ? { + $ref: `#/components/schemas/${schema}` + } : "$ref" in schema && Kind in schema && schema[Kind] === "Ref" ? { + ...schema, + $ref: `#/components/schemas/${schema.$ref}` + } : replaceSchemaType( + { ...schema }, + { + from: t.Ref(""), + // @ts-expect-error + to: ({ $ref, ...options }) => { + if (!$ref.startsWith( + "#/components/schemas/" + )) + return t.Ref( + `#/components/schemas/${$ref}`, + options + ); + return t.Ref($ref, options); + } + } + ) + }; + } + return responses; +}; +var capitalize = (word) => word.charAt(0).toUpperCase() + word.slice(1); +var generateOperationId = (method, paths) => { + let operationId = method.toLowerCase(); + if (paths === "/") return operationId + "Index"; + for (const path of paths.split("/")) { + if (path.charCodeAt(0) === 123) { + operationId += "By" + capitalize(path.slice(1, -1)); + } else { + operationId += capitalize(path); + } + } + return operationId; +}; +var cloneHook = (hook) => { + if (!hook) return; + if (typeof hook === "string") return hook; + if (Array.isArray(hook)) return [...hook]; + return { ...hook }; +}; +var registerSchemaPath = ({ + schema, + path, + method, + hook, + models +}) => { + hook = cloneHook(hook); + if (hook.parse && !Array.isArray(hook.parse)) hook.parse = [hook.parse]; + let contentType = hook.parse?.map((x) => { + switch (typeof x) { + case "string": + return x; + case "object": + if (x && typeof x?.fn !== "string") + return; + switch (x?.fn) { + case "json": + case "application/json": + return "application/json"; + case "text": + case "text/plain": + return "text/plain"; + case "urlencoded": + case "application/x-www-form-urlencoded": + return "application/x-www-form-urlencoded"; + case "arrayBuffer": + case "application/octet-stream": + return "application/octet-stream"; + case "formdata": + case "multipart/form-data": + return "multipart/form-data"; + } + } + }).filter((x) => x !== void 0); + if (!contentType || contentType.length === 0) + contentType = ["application/json", "multipart/form-data", "text/plain"]; + path = toOpenAPIPath(path); + const contentTypes = typeof contentType === "string" ? [contentType] : contentType ?? ["application/json"]; + const bodySchema = cloneHook(hook?.body); + const paramsSchema = cloneHook(hook?.params); + const headerSchema = cloneHook(hook?.headers); + const querySchema = cloneHook(hook?.query); + let responseSchema = cloneHook(hook?.response); + if (typeof responseSchema === "object") { + if (Kind in responseSchema) { + const { + type, + properties, + required, + additionalProperties, + patternProperties, + $ref, + ...rest + } = responseSchema; + responseSchema = { + "200": { + ...rest, + description: rest.description, + content: mapTypesResponse( + contentTypes, + type === "object" || type === "array" ? { + type, + properties, + patternProperties, + items: responseSchema.items, + required + } : responseSchema + ) + } + }; + } else { + Object.entries(responseSchema).forEach( + ([key, value]) => { + if (typeof value === "string") { + if (!models[value]) return; + const { + type, + properties, + required, + additionalProperties: _1, + patternProperties: _2, + ...rest + } = models[value]; + responseSchema[key] = { + ...rest, + description: rest.description, + content: mapTypesResponse(contentTypes, value) + }; + } else { + const { + type, + properties, + required, + additionalProperties, + patternProperties, + ...rest + } = value; + responseSchema[key] = { + ...rest, + description: rest.description, + content: mapTypesResponse( + contentTypes, + type === "object" || type === "array" ? { + type, + properties, + patternProperties, + items: value.items, + required + } : value + ) + }; + } + } + ); + } + } else if (typeof responseSchema === "string") { + if (!(responseSchema in models)) return; + const { + type, + properties, + required, + $ref, + additionalProperties: _1, + patternProperties: _2, + ...rest + } = models[responseSchema]; + responseSchema = { + // @ts-ignore + "200": { + ...rest, + content: mapTypesResponse(contentTypes, responseSchema) + } + }; + } + const parameters = [ + ...mapProperties("header", headerSchema, models), + ...mapProperties("path", paramsSchema, models), + ...mapProperties("query", querySchema, models) + ]; + schema[path] = { + ...schema[path] ? schema[path] : {}, + [method.toLowerCase()]: { + ...headerSchema || paramsSchema || querySchema || bodySchema ? { parameters } : {}, + ...responseSchema ? { + responses: responseSchema + } : {}, + operationId: hook?.detail?.operationId ?? generateOperationId(method, path), + ...hook?.detail, + ...bodySchema ? { + requestBody: { + required: true, + content: mapTypesResponse( + contentTypes, + typeof bodySchema === "string" ? { + $ref: `#/components/schemas/${bodySchema}` + } : bodySchema + ) + } + } : null + } + }; +}; +var filterPaths = (paths, { + excludeStaticFile = true, + exclude = [] +}) => { + const newPaths = {}; + for (const [key, value] of Object.entries(paths)) + if (!exclude.some((x) => { + if (typeof x === "string") return key === x; + return x.test(key); + }) && !key.includes("*") && (excludeStaticFile ? !key.includes(".") : true)) { + Object.keys(value).forEach((method) => { + const schema = value[method]; + if (key.includes("{")) { + if (!schema.parameters) schema.parameters = []; + schema.parameters = [ + ...key.split("/").filter( + (x) => x.startsWith("{") && !schema.parameters.find( + (params) => params.in === "path" && params.name === x.slice(1, x.length - 1) + ) + ).map((x) => ({ + schema: { type: "string" }, + in: "path", + name: x.slice(1, x.length - 1), + required: true + })), + ...schema.parameters + ]; + } + if (!schema.responses) + schema.responses = { + 200: {} + }; + }); + newPaths[key] = value; + } + return newPaths; +}; +export { + capitalize, + filterPaths, + generateOperationId, + mapProperties, + registerSchemaPath, + toOpenAPIPath +};