Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(redoc): add @hono/redoc middleware #892

Open
wants to merge 6 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/five-queens-ring.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@hono/redoc': major
---

Add test code for renderer.ts
5 changes: 5 additions & 0 deletions .changeset/nervous-cooks-grin.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@hono/redoc': major
---

Minor Changes
5 changes: 5 additions & 0 deletions .changeset/strange-beans-remain.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@hono/redoc': major
---

Minor Changes
25 changes: 25 additions & 0 deletions .github/workflows/ci-redoc.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
name: ci-redoc
on:
push:
branches: [main]
paths:
- 'packages/redoc/**'
pull_request:
branches: ['*']
paths:
- 'packages/redoc/**'

jobs:
ci:
runs-on: ubuntu-latest
defaults:
run:
working-directory: ./packages/redoc
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
5 changes: 5 additions & 0 deletions packages/redoc/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# @hono/redoc

## 0.1.0

### Minor Changes
140 changes: 140 additions & 0 deletions packages/redoc/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
# ReDoc Middleware and Component for Hono

This library, `@hono/redoc`, provides a middleware and a component for integrating ReDoc with Hono applications. ReDoc is an interactive documentation interface for APIs compliant with the OpenAPI Specification, making it easier to understand and test API endpoints.

## Installation

```bash
npm install @hono/redoc
# or
yarn add @hono/redoc
```

## Usage

### Middleware Usage

You can use the `redoc` middleware to serve ReDoc on a specific route in your Hono application. Here's how you can do it:

```ts
import { Hono } from 'hono'
import { redoc } from '@hono/redoc'

const app = new Hono()

// Use the middleware to serve ReDoc at /ui
app.get('/ui', redoc({ url: '/doc' }))

export default app
```

### Component Usage

If you are using `hono/html`, you can use the `ReDoc` component to render ReDoc within your custom HTML structure. Here's an example:

```ts
import { Hono } from 'hono'
import { html } from 'hono/html'
import { SwaggerUI } from '@hono/redoc'

const app = new Hono()

app.get('/ui', (c) => {
return c.html(`
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="description" content="Custom Swagger" />
<title>Custom ReDoc</title>
<script>
// custom script
</script>
<style>
/* custom style */
</style>
</head>
${ReDoc({ url: '/doc' })}
</html>
`)
})
export default app
```

In this example, the `ReDoc` component is used to render ReDoc within a custom HTML structure, allowing for additional customization such as adding custom scripts and styles.

### With `OpenAPIHono` Usage

Hono's middleware has OpenAPI integration `@hono/zod-openapi`, so you can use it to create an OpenAPI document and serve it easily with ReDoc.

```ts
import { OpenAPIHono, createRoute, z } from '@hono/zod-openapi'
import { swaggerUI } from '@hono/redoc'

const app = new OpenAPIHono()

app.openapi(
createRoute({
method: 'get',
path: '/hello',
responses: {
200: {
description: 'Respond a message',
content: {
'application/json': {
schema: z.object({
message: z.string()
})
}
}
}
}
}),
(c) => {
return c.json({
message: 'hello'
})
}
)

app.get(
'/ui',
redoc({
url: '/doc'
})
)

app.doc('/doc', {
info: {
title: 'An API',
version: 'v1'
},
openapi: '3.1.0'
})

export default app
```

## Options

Both the middleware and the component accept an options object for customization.

The following options are available:

- `version` (string, optional): The version of ReDoc to use, defaults to `latest`.
- `manuallyReDocHtml` (string, optional): If you want to use your own custom HTML, you can specify it here. If this option is specified, the all options except `version` will be ignored.

and most of options from [ReDoc](
https://redocly.com/docs-legacy/api-reference-docs/configuration/functionality
) are supported as well.

such as:
- `url` (string, optional): The URL pointing to the OpenAPI definition (v2 or v3) that describes the API.

## Authors

- jonghyo <https://github.com/jonghyo>

## License

MIT
53 changes: 53 additions & 0 deletions packages/redoc/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
{
"name": "@hono/redoc",
"version": "0.1.0",
"description": "A middleware for using ReDoc 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",
"test:coverage": "vitest run --coverage",
"build": "tsup ./src/index.ts --format esm,cjs --dts",
"publint": "publint",
"release": "yarn build && yarn test && yarn publint && 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": {
"@vitest/coverage-v8": "^2.1.8",
"hono": "^3.11.7",
"publint": "^0.2.12",
"redoc": "^2.2.0",
"tsup": "^8.3.5",
"vite": "5.4.11",
"vitest": "^2.1.8"
}
}
78 changes: 78 additions & 0 deletions packages/redoc/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import type { Env, MiddlewareHandler } from 'hono'
import type { RedocRawOptions } from 'redoc'
import { renderRedocOptions } from './redoc/renderer'
import type { AssetURLs } from './redoc/resource'
import { remoteAssets } from './redoc/resource'

type OriginalRedocOptions = {
version?: string
/**
* manuallyRedocHtml is a function that returns a string to customize the ReDoc HTML.
* All options except for the version are ignored.
*
* @example
* const redocUI = RedocUI({
* manuallyRedocHtml: (asset) => `
* <div>
* <script src="${asset.js[0]}" crossorigin="anonymous"></script>
* <div id="redoc-container"></div>
* <script>
* Redoc.init('https://petstore.swagger.io/v2/swagger.json', {}, document.getElementById('redoc-container'));
* </script>
* </div>
* `,
* })
*/

manuallyReDocHtml?: (asset: AssetURLs) => string
title?: string
}

type RedocOptions = OriginalRedocOptions &
RedocRawOptions & {
url: string
}

const ReDoc = (options: RedocOptions): string => {
const asset = remoteAssets({ version: options?.version })
delete options.version

if (options.manuallyReDocHtml) {
return options.manuallyReDocHtml(asset)
}

const optionsStrings = renderRedocOptions(options)

return `
<div>
<script src="${asset.js[0]}" crossorigin="anonymous"></script>
<div id="redoc-container"></div>
<script>
Redoc.init('${options.url}', {${optionsStrings}}, document.getElementById('redoc-container'));
</script>
</div>
`
}

const middleware =
<E extends Env>(options: RedocOptions): MiddlewareHandler<E> =>
async (c) => {
const title = options?.title ?? 'ReDoc'
return c.html(/* html */ `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="description" content="ReDoc" />
<title>${title}</title>
</head>
<body>
${ReDoc(options)}
</body>
</html>
`)
}

export { middleware as redoc, ReDoc }
export { RedocOptions }
72 changes: 72 additions & 0 deletions packages/redoc/src/redoc/renderer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import type { RedocRawOptions } from 'redoc'

const RENDER_TYPE_MAP: Record<keyof RedocRawOptions, 'STRING' | 'JSON_STRING' | 'RAW'> = {
theme: 'JSON_STRING',
scrollYOffset: 'RAW',
hideHostname: 'RAW',
expandResponses: 'RAW',
requiredPropsFirst: 'RAW',
sortPropsAlphabetically: 'RAW',
sortEnumValuesAlphabetically: 'RAW',
sortOperationsAlphabetically: 'RAW',
sortTagsAlphabetically: 'RAW',
nativeScrollbars: 'RAW',
pathInMiddlePanel: 'RAW',
untrustedSpec: 'RAW',
hideLoading: 'RAW',
hideDownloadButton: 'RAW',
downloadFileName: 'STRING',
downloadDefinitionUrl: 'STRING',
disableSearch: 'RAW',
onlyRequiredInSamples: 'RAW',
showExtensions: 'JSON_STRING',
sideNavStyle: 'STRING',
hideSingleRequestSampleTab: 'RAW',
hideRequestPayloadSample: 'RAW',
menuToggle: 'RAW',
jsonSampleExpandLevel: 'RAW',
hideSchemaTitles: 'RAW',
simpleOneOfTypeLabel: 'RAW',
payloadSampleIdx: 'RAW',
expandSingleSchemaField: 'RAW',
schemaExpansionLevel: 'RAW',
showObjectSchemaExamples: 'RAW',
showSecuritySchemeType: 'RAW',
hideSecuritySection: 'RAW',
unstable_ignoreMimeParameters: 'RAW',
allowedMdComponents: 'JSON_STRING',
labels: 'JSON_STRING',
enumSkipQuotes: 'RAW',
expandDefaultServerVariables: 'RAW',
maxDisplayedEnumValues: 'RAW',
ignoreNamedSchemas: 'JSON_STRING',
hideSchemaPattern: 'RAW',
generatedPayloadSamplesMaxDepth: 'RAW',
nonce: 'STRING',
hideFab: 'RAW',
minCharacterLengthToInitSearch: 'RAW',
showWebhookVerb: 'RAW',
}

export const renderRedocOptions = (options: RedocRawOptions) => {
console.log(JSON.stringify(options))
const optionsStrings = Object.entries(options)
.map(([key, value]) => {
const typedKey = key as keyof RedocRawOptions

if (RENDER_TYPE_MAP[typedKey] === 'STRING') {
return `"${key}": "${value}"`
}
if (RENDER_TYPE_MAP[typedKey] === 'JSON_STRING') {
return `"${key}": ${JSON.stringify(value)}`
}
if (RENDER_TYPE_MAP[typedKey] === 'RAW') {
return `"${key}": "${value}"`
}
return ''
})
.filter(Boolean) // 空文字列を除去
.join(',')

return optionsStrings
}
Loading