diff --git a/.changeset/tender-candles-laugh.md b/.changeset/tender-candles-laugh.md new file mode 100644 index 000000000..7a2dc7967 --- /dev/null +++ b/.changeset/tender-candles-laugh.md @@ -0,0 +1,5 @@ +--- +'@hono/swagger-editor': major +--- + +Create swagger editor middleware for hono diff --git a/.github/workflows/ci-swagger-editor.yml b/.github/workflows/ci-swagger-editor.yml new file mode 100644 index 000000000..0c9eec4e1 --- /dev/null +++ b/.github/workflows/ci-swagger-editor.yml @@ -0,0 +1,25 @@ +name: ci-swagger-editor +on: + push: + branches: [main] + paths: + - 'packages/swagger-editor/**' + pull_request: + branches: ['*'] + paths: + - 'packages/swagger-editor/**' + +jobs: + ci: + runs-on: ubuntu-latest + defaults: + run: + working-directory: ./packages/swagger-editor + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20.x + - run: yarn install --frozen-lockfile + - run: yarn build + - run: yarn test diff --git a/package.json b/package.json index 459b39b3b..e85797dbe 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "build:zod-openapi": "yarn workspace @hono/zod-openapi install && yarn workspace @hono/zod-openapi build", "build:typia-validator": "yarn workspace @hono/typia-validator build", "build:swagger-ui": "yarn workspace @hono/swagger-ui build", + "build:swagger-editor": "yarn workspace @hono/swagger-editor build", "build:esbuild-transpiler": "yarn workspace @hono/esbuild-transpiler build", "build:event-emitter": "yarn workspace @hono/event-emitter build", "build:oauth-providers": "yarn workspace @hono/oauth-providers build", diff --git a/packages/swagger-editor/README.md b/packages/swagger-editor/README.md new file mode 100644 index 000000000..b92df570b --- /dev/null +++ b/packages/swagger-editor/README.md @@ -0,0 +1,40 @@ +# Swagger Editor Middleware for Hono + +This library, `@hono/swagger-editor` is the middleware for integrating Swagger Editor with Hono applications. The Swagger Editor is an open source editor to design, define and document RESTful APIs in the Swagger Specification. + +## Installation + +```bash +npm install @hono/swagger-editor +# or +yarn add @hono/swagger-editor +``` + +## Usage + +You can use the `swaggerEditor` middleware to serve Swagger Editor on a specific route in your Hono application. Here's how you can do it: + + +```ts +import { Hono } from 'hono' +import { swaggerUI } from '@hono/swagger-ui' + +const app = new Hono() + +// Use the middleware to serve Swagger Editor at /swagger-editor +app.get('/swagger-editor', swaggerEditor({ url: '/doc' })) + +export default app +``` + +## Options + +Middleware supports almost all swagger-editor options. See full documentation: + +## Authors + +- Ogabek Yuldoshev + +## License + +MIT \ No newline at end of file diff --git a/packages/swagger-editor/package.json b/packages/swagger-editor/package.json new file mode 100644 index 000000000..74a95d6b5 --- /dev/null +++ b/packages/swagger-editor/package.json @@ -0,0 +1,48 @@ +{ + "name": "@hono/swagger-editor", + "version": "0.0.0", + "description": "A middleware for using Swagger Editor in Hono", + "type": "module", + "main": "dist/index.cjs", + "module": "dist/index.js", + "types": "dist/index.d.cts", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + } + }, + "files": [ + "dist" + ], + "scripts": { + "test": "vitest run", + "build": "tsup ./src/index.ts --format esm,cjs --dts", + "prerelease": "yarn build && yarn test", + "release": "yarn publish" + }, + "license": "MIT", + "publishConfig": { + "registry": "https://registry.npmjs.org", + "access": "public" + }, + "repository": { + "type": "git", + "url": "https://github.com/honojs/middleware.git" + }, + "homepage": "https://github.com/honojs/middleware", + "peerDependencies": { + "hono": "*" + }, + "devDependencies": { + "hono": "^3.11.7", + "tsup": "^7.2.0", + "vitest": "^0.34.5" + } +} \ No newline at end of file diff --git a/packages/swagger-editor/src/index.ts b/packages/swagger-editor/src/index.ts new file mode 100644 index 000000000..326fc7f74 --- /dev/null +++ b/packages/swagger-editor/src/index.ts @@ -0,0 +1,195 @@ +import type { Context } from 'hono' +import type { CustomSwaggerUIOptions } from './types' + +const DEFAULT_VERSION = '4.13.1' + +const CDN_LINK = 'https://cdn.jsdelivr.net/npm/swagger-editor-dist' + +export const MODERN_NORMALIZE_CSS = ` +*, +::before, +::after { + box-sizing: border-box; +} + +html { + font-family: + system-ui, + 'Segoe UI', + Roboto, + Helvetica, + Arial, + sans-serif, + 'Apple Color Emoji', + 'Segoe UI Emoji'; + line-height: 1.15; /* 1. Correct the line height in all browsers. */ + -webkit-text-size-adjust: 100%; /* 2. Prevent adjustments of font size after orientation changes in iOS. */ + tab-size: 4; /* 3. Use a more readable tab size (opinionated). */ +} + +body { + margin: 0; +} + +b, +strong { + font-weight: bolder; +} + +code, +kbd, +samp, +pre { + font-family: + ui-monospace, + SFMono-Regular, + Consolas, + 'Liberation Mono', + Menlo, + monospace; /* 1 */ + font-size: 1em; /* 2 */ +} + +small { + font-size: 80%; +} + +sub, +sup { + font-size: 75%; + line-height: 0; + position: relative; + vertical-align: baseline; +} + +sub { + bottom: -0.25em; +} + +sup { + top: -0.5em; +} + +table { + border-color: currentcolor; +} + +button, +input, +optgroup, +select, +textarea { + font-family: inherit; /* 1 */ + font-size: 100%; /* 1 */ + line-height: 1.15; /* 1 */ + margin: 0; /* 2 */ +} + +button, +[type='button'], +[type='reset'], +[type='submit'] { + -webkit-appearance: button; +} + +legend { + padding: 0; +} + +progress { + vertical-align: baseline; +} + +::-webkit-inner-spin-button, +::-webkit-outer-spin-button { + height: auto; +} + +[type='search'] { + -webkit-appearance: textfield; /* 1 */ + outline-offset: -2px; /* 2 */ +} + +::-webkit-search-decoration { + -webkit-appearance: none; +} + +::-webkit-file-upload-button { + -webkit-appearance: button; /* 1 */ + font: inherit; /* 2 */ +} + +summary { + display: list-item; +} + +.Pane2 { + overflow-y: scroll; +} +` + +function getUrl(version?: string) { + return `${CDN_LINK}@${version ? version : DEFAULT_VERSION}` +} + +export interface SwaggerEditorOptions extends CustomSwaggerUIOptions { + version?: string +} + +export function swaggerEditor(options: SwaggerEditorOptions = {}) { + const url = getUrl() + + options.layout = 'StandaloneLayout' + + const optionString = Object.entries(options) + .map(([key, value]) => { + if (typeof value === 'string') { + return `${key}:'${value}'` + } + if (Array.isArray(value)) { + return `${key}:${value.map((v) => `${v}`).join(', ')}` + } + if (typeof value === 'object') { + return `${key}:${JSON.stringify(value)}` + } + + return `${key}: ${value}` + }) + .join(',') + + return async (c: Context) => + c.html(` + + + + + Swagger Editor + + + + + + + +
+ + + + + +`) +} diff --git a/packages/swagger-editor/src/types.ts b/packages/swagger-editor/src/types.ts new file mode 100644 index 000000000..7097df54d --- /dev/null +++ b/packages/swagger-editor/src/types.ts @@ -0,0 +1,297 @@ +export interface CustomSwaggerUIOptions { + /** + * URL to fetch external configuration document from. + */ + configUrl?: 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 + + // Plugin system + + /** + * 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 + + // Display + + /** + * 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 + + // Network + + /** + * 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 + + // Macros + + /** + * 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 + + // Authorization + + /** + * 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/ + */ +type 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 +} diff --git a/packages/swagger-editor/test/index.test.ts b/packages/swagger-editor/test/index.test.ts new file mode 100644 index 000000000..c9f799103 --- /dev/null +++ b/packages/swagger-editor/test/index.test.ts @@ -0,0 +1,32 @@ +import { Hono } from 'hono' +import { swaggerEditor } from '../src' + +describe('Swagger Editor Middleware', () => { + let app: Hono + + beforeEach(() => { + app = new Hono() + }) + + it('responds with status 200', async () => { + app.get('/swagger-editor', swaggerEditor()) + + const res = await app.request('/swagger-editor') + expect(res.status).toBe(200) + }) + + it('should contents shown', async () => { + app.get( + '/swagger-editor', + swaggerEditor({ + url: 'https://petstore3.swagger.io/api/v3/openapi.json', + }) + ) + + const res = await app.request('/swagger-editor') + const html = await res.text() + + expect(html).toContain('https://petstore3.swagger.io/api/v3/openapi.json') + expect(html).toContain('https://cdn.jsdelivr.net/npm/swagger-editor-dist') + }) +}) diff --git a/packages/swagger-editor/tsconfig.json b/packages/swagger-editor/tsconfig.json new file mode 100644 index 000000000..af751bf5e --- /dev/null +++ b/packages/swagger-editor/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "types": ["vitest/globals"] + }, + "include": ["src/**/*.ts", "src/**/*.tsx"] +} diff --git a/packages/swagger-editor/vitest.config.ts b/packages/swagger-editor/vitest.config.ts new file mode 100644 index 000000000..17b54e485 --- /dev/null +++ b/packages/swagger-editor/vitest.config.ts @@ -0,0 +1,8 @@ +/// +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + globals: true, + }, +}) diff --git a/yarn.lock b/yarn.lock index dec6276d2..4a6cd2ae0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2622,6 +2622,18 @@ __metadata: languageName: unknown linkType: soft +"@hono/swagger-editor@workspace:packages/swagger-editor": + version: 0.0.0-use.local + resolution: "@hono/swagger-editor@workspace:packages/swagger-editor" + dependencies: + hono: "npm:^3.11.7" + tsup: "npm:^7.2.0" + vitest: "npm:^0.34.5" + peerDependencies: + hono: "*" + languageName: unknown + linkType: soft + "@hono/swagger-ui@workspace:packages/swagger-ui": version: 0.0.0-use.local resolution: "@hono/swagger-ui@workspace:packages/swagger-ui"