From 9361ead8c49aa060daa78d08c343695cf81d7b16 Mon Sep 17 00:00:00 2001 From: Roman Hotsiy Date: Thu, 26 Jul 2018 17:34:44 +0300 Subject: [PATCH] chore: refactor, simplify AppStore --- src/components/Schema/OneOfSchema.tsx | 2 +- .../SecuritySchemes/SecuritySchemes.tsx | 6 +- .../DiscriminatorDropdown.test.tsx.snap | 4 +- src/services/AppStore.ts | 52 ++++----- src/services/MenuBuilder.ts | 8 +- src/services/MenuStore.ts | 62 ++++------ src/services/OpenAPIParser.ts | 5 +- src/services/SpecStore.ts | 36 ++---- src/services/models/Field.ts | 3 +- src/services/models/Operation.ts | 108 ++++++++++-------- src/services/models/Schema.ts | 20 ++-- src/utils/index.ts | 1 + src/utils/memoize.ts | 49 ++++++++ 13 files changed, 193 insertions(+), 163 deletions(-) create mode 100644 src/utils/memoize.ts diff --git a/src/components/Schema/OneOfSchema.tsx b/src/components/Schema/OneOfSchema.tsx index bc7364c9..8574ff40 100644 --- a/src/components/Schema/OneOfSchema.tsx +++ b/src/components/Schema/OneOfSchema.tsx @@ -47,7 +47,7 @@ export class OneOfSchema extends React.Component { {schema.oneOfType} {oneOf.map((subSchema, idx) => ( - + ))} diff --git a/src/components/SecuritySchemes/SecuritySchemes.tsx b/src/components/SecuritySchemes/SecuritySchemes.tsx index 59f100bf..cff9ecb4 100644 --- a/src/components/SecuritySchemes/SecuritySchemes.tsx +++ b/src/components/SecuritySchemes/SecuritySchemes.tsx @@ -61,15 +61,11 @@ export class OAuthFlow extends React.PureComponent { } export interface SecurityDefsProps { - securitySchemes?: SecuritySchemesModel; + securitySchemes: SecuritySchemesModel; } export class SecurityDefs extends React.PureComponent { render() { - if (!this.props.securitySchemes) { - return null; - } - return (
{this.props.securitySchemes.schemes.map(scheme => ( diff --git a/src/components/__tests__/__snapshots__/DiscriminatorDropdown.test.tsx.snap b/src/components/__tests__/__snapshots__/DiscriminatorDropdown.test.tsx.snap index 2e689d7a..ab1a367c 100644 --- a/src/components/__tests__/__snapshots__/DiscriminatorDropdown.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/DiscriminatorDropdown.test.tsx.snap @@ -15,7 +15,6 @@ exports[`Components SchemaView discriminator should correctly render discriminat "name": "packSize", "required": false, "schema": SchemaModel { - "_$ref": "#/components/schemas/Dog/properties/packSize/schema", "activeOneOf": 0, "constraints": Array [], "default": undefined, @@ -31,6 +30,7 @@ exports[`Components SchemaView discriminator should correctly render discriminat "nullable": false, "options": "<<>>", "pattern": undefined, + "pointer": "#/components/schemas/Dog/properties/packSize", "rawSchema": Object { "default": undefined, "type": "number", @@ -63,7 +63,6 @@ exports[`Components SchemaView discriminator should correctly render discriminat "name": "type", "required": true, "schema": SchemaModel { - "_$ref": "#/components/schemas/Dog/properties/type/schema", "activeOneOf": 0, "constraints": Array [], "default": undefined, @@ -79,6 +78,7 @@ exports[`Components SchemaView discriminator should correctly render discriminat "nullable": false, "options": "<<>>", "pattern": undefined, + "pointer": "#/components/schemas/Dog/properties/type", "rawSchema": Object { "default": undefined, "type": "string", diff --git a/src/services/AppStore.ts b/src/services/AppStore.ts index 3ec3433f..1d3614f0 100644 --- a/src/services/AppStore.ts +++ b/src/services/AppStore.ts @@ -90,7 +90,32 @@ export class AppStore { this.updateMarkOnMenu(this.menu.activeItemIdx); } - updateMarkOnMenu(idx: number) { + dispose() { + this.scroll.dispose(); + this.menu.dispose(); + this.disposer(); + } + + /** + * serializes store + * **SUPER HACKY AND NOT OPTIMAL IMPLEMENTATION** + */ + // TODO: improve + async toJS(): Promise { + return { + menu: { + activeItemIdx: this.menu.activeItemIdx, + }, + spec: { + url: this.spec.parser.specUrl, + data: this.spec.parser.spec, + }, + searchIndex: this.search ? await this.search.toJS() : undefined, + options: this.rawOptions, + }; + } + + private updateMarkOnMenu(idx: number) { const start = Math.max(0, idx); const end = Math.min(this.menu.flatItems.length, start + 5); @@ -111,29 +136,4 @@ export class AppStore { this.marker.addOnly(elements); this.marker.mark(); } - - dispose() { - this.scroll.dispose(); - this.menu.dispose(); - this.disposer(); - } - - /** - * serializes store - * **SUPER HACKY AND NOT OPTIMAL IMPLEMENTATION** - */ - // TODO: - async toJS(): Promise { - return { - menu: { - activeItemIdx: this.menu.activeItemIdx, - }, - spec: { - url: this.spec.parser.specUrl, - data: this.spec.parser.spec, - }, - searchIndex: this.search ? await this.search.toJS() : undefined, - options: this.rawOptions, - }; - } } diff --git a/src/services/MenuBuilder.ts b/src/services/MenuBuilder.ts index 441e780a..f1791bf8 100644 --- a/src/services/MenuBuilder.ts +++ b/src/services/MenuBuilder.ts @@ -1,5 +1,5 @@ import { OpenAPIOperation, OpenAPIParameter, OpenAPISpec, OpenAPITag, Referenced } from '../types'; -import { isOperationName, JsonPointer } from '../utils'; +import { isOperationName } from '../utils'; import { MarkdownRenderer } from './MarkdownRenderer'; import { GroupModel, OperationModel } from './models'; import { OpenAPIParser } from './OpenAPIParser'; @@ -11,7 +11,7 @@ export type TagInfo = OpenAPITag & { }; export type ExtendedOpenAPIOperation = { - _$ref: string; + pathName: string; httpVerb: string; pathParameters: Array>; } & OpenAPIOperation; @@ -190,7 +190,7 @@ export class MenuBuilder { // empty tag operationTags = ['']; } - const operationPointer = JsonPointer.compile(['paths', pathName, operationName]); + for (const tagName of operationTags) { let tag = tags[tagName]; if (tag === undefined) { @@ -205,7 +205,7 @@ export class MenuBuilder { } tag.operations.push({ ...operationInfo, - _$ref: operationPointer, + pathName, httpVerb: operationName, pathParameters: path.parameters || [], }); diff --git a/src/services/MenuStore.ts b/src/services/MenuStore.ts index d1eb5a41..05dc5bd3 100644 --- a/src/services/MenuStore.ts +++ b/src/services/MenuStore.ts @@ -1,6 +1,6 @@ -import { action, computed, observable } from 'mobx'; +import { action, observable } from 'mobx'; import { querySelector } from '../utils/dom'; -import { GroupModel, OperationModel, SpecStore } from './models'; +import { SpecStore } from './models'; import { HistoryService } from './HistoryService'; import { ScrollService } from './ScrollService'; @@ -55,20 +55,31 @@ export class MenuStore { */ @observable sideBarOpened: boolean = false; + items: IMenuItem[]; + flatItems: IMenuItem[]; + /** * cached flattened menu items to support absolute indexing */ private _unsubscribe: () => void; private _hashUnsubscribe: () => void; - private _items?: Array; /** * * @param spec [SpecStore](#SpecStore) which contains page content structure - * @param _scrollService scroll service instance used by this menu + * @param scroll scroll service instance used by this menu */ - constructor(private spec: SpecStore, private _scrollService: ScrollService) { - this._unsubscribe = _scrollService.subscribe(this.updateOnScroll); + constructor(spec: SpecStore, public scroll: ScrollService) { + this.items = spec.operationGroups; + + this.flatItems = flattenByProp(this.items || [], 'items'); + this.flatItems.forEach((item, idx) => (item.absoluteIdx = idx)); + + this.subscribe(); + } + + subscribe() { + this._unsubscribe = this.scroll.subscribe(this.updateOnScroll); this._hashUnsubscribe = HistoryService.subscribe(this.updateOnHash); } @@ -82,23 +93,11 @@ export class MenuStore { this.sideBarOpened = false; } - /** - * top level menu items (not flattened) - */ - @computed - get items(): IMenuItem[] { - if (!this._items) { - this._items = this.spec.operationGroups; - } - return this._items; - } - /** * update active items on scroll * @param isScrolledDown whether last scroll was downside */ - @action.bound - updateOnScroll(isScrolledDown: boolean): void { + updateOnScroll = (isScrolledDown: boolean): void => { const step = isScrolledDown ? 1 : -1; let itemIdx = this.activeItemIdx; while (true) { @@ -112,12 +111,12 @@ export class MenuStore { if (isScrolledDown) { const el = this.getElementAt(itemIdx + 1); - if (this._scrollService.isElementBellow(el)) { + if (this.scroll.isElementBellow(el)) { break; } } else { const el = this.getElementAt(itemIdx); - if (this._scrollService.isElementAbove(el)) { + if (this.scroll.isElementAbove(el)) { break; } } @@ -125,14 +124,13 @@ export class MenuStore { } this.activate(this.flatItems[itemIdx], true, true); - } + }; /** * update active items on hash change * @param hash current hash */ - @action.bound - updateOnHash(hash: string = HistoryService.hash): boolean { + updateOnHash = (hash: string = HistoryService.hash): boolean => { if (!hash) { return false; } @@ -143,10 +141,10 @@ export class MenuStore { if (item) { this.activateAndScroll(item, false); } else { - this._scrollService.scrollIntoViewBySelector(`[${SECTION_ATTR}="${hash}"]`); + this.scroll.scrollIntoViewBySelector(`[${SECTION_ATTR}="${hash}"]`); } return item !== undefined; - } + }; /** * get section/operation DOM Node related to the item or null if it doesn't exist @@ -168,16 +166,6 @@ export class MenuStore { return this.flatItems.find(item => item.id === id); }; - /** - * flattened items as they appear in the tree depth-first (top to bottom in the view) - */ - @computed - get flatItems(): IMenuItem[] { - const flatItems = flattenByProp(this._items || [], 'items'); - flatItems.forEach((item, idx) => (item.absoluteIdx = idx)); - return flatItems; - } - /** * activate menu item * @param item item to activate @@ -246,7 +234,7 @@ export class MenuStore { * scrolls to active section */ scrollToActive(): void { - this._scrollService.scrollIntoView(this.getElementAt(this.activeItemIdx)); + this.scroll.scrollIntoView(this.getElementAt(this.activeItemIdx)); } dispose() { diff --git a/src/services/OpenAPIParser.ts b/src/services/OpenAPIParser.ts index c27e89c7..ab807efc 100644 --- a/src/services/OpenAPIParser.ts +++ b/src/services/OpenAPIParser.ts @@ -1,4 +1,3 @@ -import { observable } from 'mobx'; import { resolve as urlResolve } from 'url'; import { OpenAPIRef, OpenAPISchema, OpenAPISpec, Referenced } from '../types'; @@ -39,8 +38,8 @@ class RefCounter { * Loads and keeps spec. Provides raw spec operations */ export class OpenAPIParser { - @observable specUrl?: string; - @observable.ref spec: OpenAPISpec; + specUrl?: string; + spec: OpenAPISpec; private _refCounter: RefCounter = new RefCounter(); diff --git a/src/services/SpecStore.ts b/src/services/SpecStore.ts index b2f210ed..2f0ab5e5 100644 --- a/src/services/SpecStore.ts +++ b/src/services/SpecStore.ts @@ -1,7 +1,6 @@ -import { computed, observable } from 'mobx'; -import { OpenAPISpec } from '../types'; +import { OpenAPIExternalDocumentation, OpenAPISpec } from '../types'; -import { MenuBuilder } from './MenuBuilder'; +import { ContentItemModel, MenuBuilder } from './MenuBuilder'; import { ApiInfoModel } from './models/ApiInfo'; import { SecuritySchemesModel } from './models/SecuritySchemes'; import { OpenAPIParser } from './OpenAPIParser'; @@ -10,7 +9,12 @@ import { RedocNormalizedOptions } from './RedocNormalizedOptions'; * Store that containts all the specification related information in the form of tree */ export class SpecStore { - @observable.ref parser: OpenAPIParser; + parser: OpenAPIParser; + + info: ApiInfoModel; + externalDocs?: OpenAPIExternalDocumentation; + operationGroups: ContentItemModel[]; + securitySchemes: SecuritySchemesModel; constructor( spec: OpenAPISpec, @@ -18,26 +22,10 @@ export class SpecStore { private options: RedocNormalizedOptions, ) { this.parser = new OpenAPIParser(spec, specUrl, options); - } + this.info = new ApiInfoModel(this.parser); + this.externalDocs = this.parser.spec.externalDocs; + this.operationGroups = MenuBuilder.buildStructure(this.parser, this.options); - @computed - get info(): ApiInfoModel { - return new ApiInfoModel(this.parser); - } - - @computed - get externalDocs() { - return this.parser.spec.externalDocs; - } - - @computed - get operationGroups() { - return MenuBuilder.buildStructure(this.parser, this.options); - } - - @computed - get securitySchemes() { - const schemes = this.parser.spec.components && this.parser.spec.components.securitySchemes; - return schemes && new SecuritySchemesModel(this.parser); + this.securitySchemes = new SecuritySchemesModel(this.parser); } } diff --git a/src/services/models/Field.ts b/src/services/models/Field.ts index b2405844..1dba916f 100644 --- a/src/services/models/Field.ts +++ b/src/services/models/Field.ts @@ -32,8 +32,7 @@ export class FieldModel { this.name = infoOrRef.name || info.name; this.in = info.in; this.required = !!info.required; - const schemaPointer = (parser.isRef(infoOrRef) ? infoOrRef.$ref : pointer) + '/schema'; - this.schema = new SchemaModel(parser, info.schema || {}, schemaPointer, options); + this.schema = new SchemaModel(parser, info.schema || {}, pointer, options); this.description = info.description === undefined ? this.schema.description || '' : info.description; this.example = info.example || this.schema.example; diff --git a/src/services/models/Operation.ts b/src/services/models/Operation.ts index 4e103ec7..b589f108 100644 --- a/src/services/models/Operation.ts +++ b/src/services/models/Operation.ts @@ -11,6 +11,7 @@ import { getStatusCodeType, isStatusCode, JsonPointer, + memoize, mergeParams, normalizeServers, sortByRequired, @@ -22,7 +23,6 @@ import { FieldModel } from './Field'; import { RequestBodyModel } from './RequestBody'; import { ResponseModel } from './Response'; import { CodeSample } from './types'; - /** * Operation model ready to be used by components */ @@ -44,80 +44,41 @@ export class OperationModel implements IMenuItem { @observable active: boolean = false; //#endregion - _$ref: string; + pointer: string; operationId?: string; httpVerb: string; deprecated: boolean; - requestBody?: RequestBodyModel; - parameters: FieldModel[]; - responses: ResponseModel[]; path: string; servers: OpenAPIServer[]; security: SecurityRequirementModel[]; codeSamples: CodeSample[]; constructor( - parser: OpenAPIParser, - operationSpec: ExtendedOpenAPIOperation, + private parser: OpenAPIParser, + private operationSpec: ExtendedOpenAPIOperation, parent: GroupModel | undefined, - options: RedocNormalizedOptions, + private options: RedocNormalizedOptions, ) { + this.pointer = JsonPointer.compile(['paths', operationSpec.pathName, operationSpec.httpVerb]); + this.id = operationSpec.operationId !== undefined ? 'operation/' + operationSpec.operationId : parent !== undefined - ? parent.id + operationSpec._$ref - : operationSpec._$ref; + ? parent.id + this.pointer + : this.pointer; this.name = getOperationSummary(operationSpec); this.description = operationSpec.description; - this.parent = parent; this.externalDocs = operationSpec.externalDocs; - this._$ref = operationSpec._$ref; this.deprecated = !!operationSpec.deprecated; this.httpVerb = operationSpec.httpVerb; this.deprecated = !!operationSpec.deprecated; this.operationId = operationSpec.operationId; - this.requestBody = - operationSpec.requestBody && new RequestBodyModel(parser, operationSpec.requestBody, options); this.codeSamples = operationSpec['x-code-samples'] || []; - this.path = JsonPointer.baseName(this._$ref, 2); - - this.parameters = mergeParams( - parser, - operationSpec.pathParameters, - operationSpec.parameters, - ).map(paramOrRef => new FieldModel(parser, paramOrRef, this._$ref, options)); - - if (options.requiredPropsFirst) { - sortByRequired(this.parameters); - } - - let hasSuccessResponses = false; - this.responses = Object.keys(operationSpec.responses || []) - .filter(code => { - if (code === 'default') { - return true; - } - - if (getStatusCodeType(code) === 'success') { - hasSuccessResponses = true; - } - - return isStatusCode(code); - }) // filter out other props (e.g. x-props) - .map(code => { - return new ResponseModel( - parser, - code, - hasSuccessResponses, - operationSpec.responses[code], - options, - ); - }); - + this.path = operationSpec.pathName; this.servers = normalizeServers( parser.specUrl, operationSpec.servers || parser.spec.servers || [], @@ -143,4 +104,53 @@ export class OperationModel implements IMenuItem { deactivate() { this.active = false; } + + @memoize + get requestBody() { + return ( + this.operationSpec.requestBody && + new RequestBodyModel(this.parser, this.operationSpec.requestBody, this.options) + ); + } + + @memoize + get parameters() { + const _parameters = mergeParams( + this.parser, + this.operationSpec.pathParameters, + this.operationSpec.parameters, + // TODO: fix pointer + ).map(paramOrRef => new FieldModel(this.parser, paramOrRef, this.pointer, this.options)); + + if (this.options.requiredPropsFirst) { + sortByRequired(_parameters); + } + return _parameters; + } + + @memoize + get responses() { + let hasSuccessResponses = false; + return Object.keys(this.operationSpec.responses || []) + .filter(code => { + if (code === 'default') { + return true; + } + + if (getStatusCodeType(code) === 'success') { + hasSuccessResponses = true; + } + + return isStatusCode(code); + }) // filter out other props (e.g. x-props) + .map(code => { + return new ResponseModel( + this.parser, + code, + hasSuccessResponses, + this.operationSpec.responses[code], + this.options, + ); + }); + } } diff --git a/src/services/models/Schema.ts b/src/services/models/Schema.ts index a51db819..f8a196ab 100644 --- a/src/services/models/Schema.ts +++ b/src/services/models/Schema.ts @@ -18,7 +18,7 @@ import { // TODO: refactor this model, maybe use getters instead of copying all the values export class SchemaModel { - _$ref: string; + pointer: string; type: string; displayType: string; @@ -60,13 +60,13 @@ export class SchemaModel { constructor( parser: OpenAPIParser, schemaOrRef: Referenced, - $ref: string, + pointer: string, private options: RedocNormalizedOptions, isChild: boolean = false, ) { - this._$ref = schemaOrRef.$ref || $ref || ''; + this.pointer = schemaOrRef.$ref || pointer || ''; this.rawSchema = parser.deref(schemaOrRef); - this.schema = parser.mergeAllOf(this.rawSchema, this._$ref, isChild); + this.schema = parser.mergeAllOf(this.rawSchema, this.pointer, isChild); this.init(parser, isChild); parser.exitRef(schemaOrRef); @@ -91,7 +91,7 @@ export class SchemaModel { this.isCircular = schema['x-circular-ref']; this.title = - schema.title || (isNamedDefinition(this._$ref) && JsonPointer.baseName(this._$ref)) || ''; + schema.title || (isNamedDefinition(this.pointer) && JsonPointer.baseName(this.pointer)) || ''; this.description = schema.description || ''; this.type = schema.type || detectType(schema); this.format = schema.format; @@ -123,7 +123,7 @@ export class SchemaModel { this.oneOfType = 'One of'; if (schema.anyOf !== undefined) { console.warn( - `oneOf and anyOf are not supported on the same level. Skipping anyOf at ${this._$ref}`, + `oneOf and anyOf are not supported on the same level. Skipping anyOf at ${this.pointer}`, ); } return; @@ -136,9 +136,9 @@ export class SchemaModel { } if (this.type === 'object') { - this.fields = buildFields(parser, schema, this._$ref, this.options); + this.fields = buildFields(parser, schema, this.pointer, this.options); } else if (this.type === 'array' && schema.items) { - this.items = new SchemaModel(parser, schema.items, this._$ref + '/items', this.options); + this.items = new SchemaModel(parser, schema.items, this.pointer + '/items', this.options); this.displayType = this.items.displayType; this.displayFormat = this.items.format; this.typePrefix = this.items.typePrefix + 'Array of '; @@ -162,7 +162,7 @@ export class SchemaModel { // merge base schema into each of oneOf's subschemas allOf: [variant, { ...this.schema, oneOf: undefined, anyOf: undefined }], } as OpenAPISchema, - this._$ref + '/oneOf/' + idx, + this.pointer + '/oneOf/' + idx, this.options, ), ); @@ -177,7 +177,7 @@ export class SchemaModel { ) { const discriminator = getDiscriminator(schema)!; this.discriminatorProp = discriminator.propertyName; - const derived = parser.findDerived([...(schema.parentRefs || []), this._$ref]); + const derived = parser.findDerived([...(schema.parentRefs || []), this.pointer]); if (schema.oneOf) { for (const variant of schema.oneOf) { diff --git a/src/utils/index.ts b/src/utils/index.ts index 846494fa..dd19252f 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -7,3 +7,4 @@ export * from './loadAndBundleSpec'; export * from './dom'; export * from './decorators'; export * from './debug'; +export * from './memoize'; diff --git a/src/utils/memoize.ts b/src/utils/memoize.ts new file mode 100644 index 00000000..ecdfd7f8 --- /dev/null +++ b/src/utils/memoize.ts @@ -0,0 +1,49 @@ +// source: https://github.com/andreypopp/memoize-decorator +const SENTINEL = {}; + +export function memoize(target: any, name: string, descriptor: TypedPropertyDescriptor) { + if (typeof descriptor.value === 'function') { + return (_memoizeMethod(target, name, descriptor) as any) as TypedPropertyDescriptor; + } else if (typeof descriptor.get === 'function') { + return _memoizeGetter(target, name, descriptor) as TypedPropertyDescriptor; + } else { + throw new Error( + '@memoize decorator can be applied to methods or getters, got ' + + String(descriptor.value) + + ' instead', + ); + } +} + +function _memoizeGetter(target: any, name: string, descriptor: PropertyDescriptor) { + const memoizedName = `_memoized_${name}`; + const get = descriptor.get!; + target[memoizedName] = SENTINEL; + return { + ...descriptor, + get() { + if (this[memoizedName] === SENTINEL) { + this[memoizedName] = get.call(this); + } + return this[memoizedName]; + }, + }; +} + +function _memoizeMethod(target: any, name: string, descriptor: TypedPropertyDescriptor) { + if (!descriptor.value || (descriptor.value as any).length > 0) { + throw new Error('@memoize decorator can only be applied to methods of zero arguments'); + } + const memoizedName = `_memoized_${name}`; + const value = descriptor.value; + target[memoizedName] = SENTINEL; + return { + ...descriptor, + value() { + if (this[memoizedName] === SENTINEL) { + this[memoizedName] = (value as any).call(this); + } + return this[memoizedName] as any; + }, + }; +}