diff --git a/README.md b/README.md index e5db33c1..f2efc98e 100644 --- a/README.md +++ b/README.md @@ -232,6 +232,7 @@ You can use all of the following options with standalone version on tag * `expandResponses` - specify which responses to expand by default by response codes. Values should be passed as comma-separated list without spaces e.g. `expandResponses="200,201"`. Special value `"all"` expands all responses by default. Be careful: this option can slow-down documentation rendering time. * `maxDisplayedEnumValues` - display only specified number of enum values. hide rest values under spoiler. * `hideDownloadButton` - do not show "Download" spec button. **THIS DOESN'T MAKE YOUR SPEC PRIVATE**, it just hides the button. +* `downloadFileName` - set file name and format of downloaded spec file, e.g. `openapi.yaml`, default `swagger.json`. * `hideHostname` - if set, the protocol and hostname is not shown in the operation definition. * `hideLoading` - do not show loading animation. Useful for small docs. * `hideSchemaPattern` - if set, the pattern is not shown in the schema. diff --git a/package-lock.json b/package-lock.json index 79a0831f..8e4dfaa2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "decko": "^1.2.0", "dompurify": "^2.2.8", "eventemitter3": "^4.0.7", + "js-yaml": "^4.1.0", "json-pointer": "^0.6.1", "lunr": "^2.3.9", "mark.js": "^8.11.1", @@ -90,7 +91,6 @@ "fork-ts-checker-webpack-plugin": "^6.2.10", "html-webpack-plugin": "^5.3.1", "jest": "^27.0.3", - "js-yaml": "^4.1.0", "license-checker": "^25.0.1", "lodash": "^4.17.21", "mobx": "^6.3.2", @@ -3852,8 +3852,7 @@ "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" }, "node_modules/arr-diff": { "version": "4.0.0", @@ -13118,7 +13117,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dev": true, "dependencies": { "argparse": "^2.0.1" }, @@ -23957,8 +23955,7 @@ "argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" }, "arr-diff": { "version": "4.0.0", @@ -31064,7 +31061,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dev": true, "requires": { "argparse": "^2.0.1" } diff --git a/package.json b/package.json index ead71f3e..4cdc4721 100644 --- a/package.json +++ b/package.json @@ -112,7 +112,6 @@ "fork-ts-checker-webpack-plugin": "^6.2.10", "html-webpack-plugin": "^5.3.1", "jest": "^27.0.3", - "js-yaml": "^4.1.0", "license-checker": "^25.0.1", "lodash": "^4.17.21", "mobx": "^6.3.2", @@ -154,6 +153,7 @@ "dompurify": "^2.2.8", "eventemitter3": "^4.0.7", "json-pointer": "^0.6.1", + "js-yaml": "^4.1.0", "lunr": "^2.3.9", "mark.js": "^8.11.1", "marked": "^0.7.0", diff --git a/src/services/RedocNormalizedOptions.ts b/src/services/RedocNormalizedOptions.ts index 58d4b8b0..f8c42ffb 100644 --- a/src/services/RedocNormalizedOptions.ts +++ b/src/services/RedocNormalizedOptions.ts @@ -1,3 +1,4 @@ +import * as path from 'path'; import defaultTheme, { ResolvedThemeInterface, resolveTheme, ThemeInterface } from '../theme'; import { querySelector } from '../utils/dom'; import { isNumeric, mergeObjects } from '../utils/helpers'; @@ -19,6 +20,7 @@ export interface RedocRawOptions { untrustedSpec?: boolean | string; hideLoading?: boolean | string; hideDownloadButton?: boolean | string; + downloadFileName?: string; disableSearch?: boolean | string; onlyRequiredInSamples?: boolean | string; showExtensions?: boolean | string | string[]; @@ -153,6 +155,20 @@ export class RedocNormalizedOptions { return 0; } + static normalizeDownloadFileName(value: RedocRawOptions['downloadFileName']): string { + if (value) { + const extname = path.extname(value); + if (extname === '.json' || extname === '.yaml') { + return value; + } else { + console.warn(`downloadFileName must be a JSON or YAML file.`); + } + } + + // Default value + return 'swagger.json'; + } + private static normalizeJsonSampleExpandLevel(level?: number | string | 'all'): number { if (level === 'all') { return +Infinity; @@ -175,6 +191,7 @@ export class RedocNormalizedOptions { pathInMiddlePanel: boolean; untrustedSpec: boolean; hideDownloadButton: boolean; + downloadFileName: string; disableSearch: boolean; onlyRequiredInSamples: boolean; showExtensions: boolean | string[]; @@ -232,6 +249,7 @@ export class RedocNormalizedOptions { this.pathInMiddlePanel = argValueToBoolean(raw.pathInMiddlePanel); this.untrustedSpec = argValueToBoolean(raw.untrustedSpec); this.hideDownloadButton = argValueToBoolean(raw.hideDownloadButton); + this.downloadFileName = RedocNormalizedOptions.normalizeDownloadFileName(raw.downloadFileName); this.disableSearch = argValueToBoolean(raw.disableSearch); this.onlyRequiredInSamples = argValueToBoolean(raw.onlyRequiredInSamples); this.showExtensions = RedocNormalizedOptions.normalizeShowExtensions(raw.showExtensions); diff --git a/src/services/SpecStore.ts b/src/services/SpecStore.ts index 277e2c03..f6cdba07 100644 --- a/src/services/SpecStore.ts +++ b/src/services/SpecStore.ts @@ -24,7 +24,7 @@ export class SpecStore { private options: RedocNormalizedOptions, ) { this.parser = new OpenAPIParser(spec, specUrl, options); - this.info = new ApiInfoModel(this.parser); + this.info = new ApiInfoModel(this.parser, this.options); this.externalDocs = this.parser.spec.externalDocs; this.contentItems = MenuBuilder.buildStructure(this.parser, this.options); this.securitySchemes = new SecuritySchemesModel(this.parser); diff --git a/src/services/__tests__/models/ApiInfo.test.ts b/src/services/__tests__/models/ApiInfo.test.ts index 5db42527..c1f89685 100644 --- a/src/services/__tests__/models/ApiInfo.test.ts +++ b/src/services/__tests__/models/ApiInfo.test.ts @@ -62,5 +62,43 @@ describe('Models', () => { const { license = { identifier: null } } = new ApiInfoModel(parser); expect(license.identifier).toEqual('MIT'); }); + + test('should correctly populate default download file name', () => { + parser.spec = { + openapi: '3.0.0', + info: { + description: 'Test description', + }, + } as any; + + const info = new ApiInfoModel(parser); + expect(info.downloadFileName).toEqual('swagger.json'); + }); + + test('should correctly populate download file name', () => { + parser.spec = { + openapi: '3.0.0', + info: { + description: 'Test description', + }, + } as any; + + const opts = new RedocNormalizedOptions({downloadFileName: 'openapi.yaml'}); + const info = new ApiInfoModel(parser, opts); + expect(info.downloadFileName).toEqual('openapi.yaml'); + }); + + test('should correctly populate default download file name if invalid extension is used', () => { + parser.spec = { + openapi: '3.0.0', + info: { + description: 'Test description', + }, + } as any; + + const opts = new RedocNormalizedOptions({downloadFileName: 'nope.txt'}); + const info = new ApiInfoModel(parser, opts); + expect(info.downloadFileName).toEqual('swagger.json'); + }); }); }); diff --git a/src/services/models/ApiInfo.ts b/src/services/models/ApiInfo.ts index 517538db..8b56d1bb 100644 --- a/src/services/models/ApiInfo.ts +++ b/src/services/models/ApiInfo.ts @@ -1,6 +1,9 @@ +import * as path from 'path'; +import * as yaml from 'js-yaml' import { OpenAPIContact, OpenAPIInfo, OpenAPILicense } from '../../types'; import { IS_BROWSER } from '../../utils/'; import { OpenAPIParser } from '../OpenAPIParser'; +import { RedocNormalizedOptions } from '../RedocNormalizedOptions'; export class ApiInfoModel implements OpenAPIInfo { title: string; @@ -15,7 +18,10 @@ export class ApiInfoModel implements OpenAPIInfo { downloadLink?: string; downloadFileName?: string; - constructor(private parser: OpenAPIParser) { + constructor( + private parser: OpenAPIParser, + private options: RedocNormalizedOptions = new RedocNormalizedOptions({}), + ) { Object.assign(this, parser.spec.info); this.description = parser.spec.info.description || ''; this.summary = parser.spec.info.summary || ''; @@ -35,7 +41,13 @@ export class ApiInfoModel implements OpenAPIInfo { } if (IS_BROWSER && window.Blob && window.URL && window.URL.createObjectURL) { - const blob = new Blob([JSON.stringify(this.parser.spec, null, 2)], { + let specString: string; + if (path.extname(this.options.downloadFileName) === '.yaml') { + specString = yaml.safeDump(this.parser.spec); + } else { + specString = JSON.stringify(this.parser.spec, null, 2); + } + const blob = new Blob([specString], { type: 'application/json', }); return window.URL.createObjectURL(blob); @@ -44,7 +56,7 @@ export class ApiInfoModel implements OpenAPIInfo { private getDownloadFileName(): string | undefined { if (!this.parser.specUrl) { - return 'swagger.json'; + return this.options.downloadFileName; } return undefined; }