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: Add download file option #1699

Merged
merged 14 commits into from
May 10, 2022
Merged
Show file tree
Hide file tree
Changes from 3 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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,7 @@ You can use all of the following options with standalone version on <redoc> 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`.
ivana-isadora marked this conversation as resolved.
Show resolved Hide resolved
* `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.
Expand Down
10 changes: 3 additions & 7 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
18 changes: 18 additions & 0 deletions src/services/RedocNormalizedOptions.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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[];
Expand Down Expand Up @@ -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';
zachwhaley marked this conversation as resolved.
Show resolved Hide resolved
}

private static normalizeJsonSampleExpandLevel(level?: number | string | 'all'): number {
if (level === 'all') {
return +Infinity;
Expand All @@ -175,6 +191,7 @@ export class RedocNormalizedOptions {
pathInMiddlePanel: boolean;
untrustedSpec: boolean;
hideDownloadButton: boolean;
downloadFileName: string;
disableSearch: boolean;
onlyRequiredInSamples: boolean;
showExtensions: boolean | string[];
Expand Down Expand Up @@ -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);
Expand Down
7 changes: 5 additions & 2 deletions src/services/SpecStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,14 @@ 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);
const webhookPath: Referenced<OpenAPIPath> = {...this.parser?.spec?.['x-webhooks'], ...this.parser?.spec.webhooks};
const webhookPath: Referenced<OpenAPIPath> = {
...this.parser?.spec?.['x-webhooks'],
...this.parser?.spec.webhooks,
};
this.webhooks = new WebhookModel(this.parser, options, webhookPath);
}
}
42 changes: 40 additions & 2 deletions src/services/__tests__/models/ApiInfo.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,13 +54,51 @@ describe('Models', () => {
license: {
name: 'MIT',
identifier: 'MIT',
url: 'https://opensource.org/licenses/MIT'
}
url: 'https://opensource.org/licenses/MIT',
},
},
} as any;

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');
});
});
});
18 changes: 15 additions & 3 deletions src/services/models/ApiInfo.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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 || '';
Expand All @@ -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') {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we ignore the yaml dumper as it adds a lot of code to the bundle: https://github.com/Redocly/redoc/blob/master/webpack.config.ts#L116

Can we instead implement a downloadUrl option so you can provide any URL and don't have Redoc to serialise the definition.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@RomanHotsiy I'm not sure I understand what a downloadUrl option would entail. Are you suggesting users would provide their own URL for downloading a YAML API spec? If so, what if the URL they provide does not contain the API spec or is inaccessible?

I had originally hoped that the original API spec file used to create the webpage would be available to use for download, but that did not seem to be the case or I did not know where to look for it.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The original URL should work fine as long as you use URL and not use redoc-cli from a file on the filesystem (it should actually work like that but there may be a bug or something).

So I think it should work as below:

  • if URL was used in Redoc settings - the download button should just point to this URL
  • if a local file was used (e.g. redoc-cli) - it should use user-provided downloadUrl.
  • if a local file was used and no downloadUrl was provided, then we just serialize it to json and use openapi.json as filename (JSON is free in terms of bundle size vs yaml).

What do you think?

If so, what if the URL they provide does not contain the API spec or is inaccessible?

The purpose of this option is to provide the URL for download button. Why should anybody provide a bad URL there?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, I wasn't familiar with the multiple options for providing an API spec file; I believe our use case uses the redoc-cli.

So is there already an API spec URL that can be referenced in the code? Or are you suggesting this be added; if so, would this URL be used to generate the HTML or would it just be for downloading an API spec file?

Why should anybody provide a bad URL there?

I don't think it would be intentional, e.g. is it possible to use an internal or local company URL to generate the HTML pages? If so, that URL might not be accessible to the wider internet.

specString = yaml.dump(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);
Expand All @@ -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;
}
Expand Down