chore: refactor, simplify AppStore

This commit is contained in:
Roman Hotsiy 2018-07-26 17:34:44 +03:00
parent 757a92e425
commit 9361ead8c4
No known key found for this signature in database
GPG Key ID: 5CB7B3ACABA57CB0
13 changed files with 193 additions and 163 deletions

View File

@ -47,7 +47,7 @@ export class OneOfSchema extends React.Component<SchemaProps> {
<OneOfLabel> {schema.oneOfType} </OneOfLabel>
<OneOfList>
{oneOf.map((subSchema, idx) => (
<OneOfButton key={subSchema._$ref} schema={schema} subSchema={subSchema} idx={idx} />
<OneOfButton key={subSchema.pointer} schema={schema} subSchema={subSchema} idx={idx} />
))}
</OneOfList>
<Schema {...this.props} schema={oneOf[schema.activeOneOf]} />

View File

@ -61,15 +61,11 @@ export class OAuthFlow extends React.PureComponent<OAuthFlowProps> {
}
export interface SecurityDefsProps {
securitySchemes?: SecuritySchemesModel;
securitySchemes: SecuritySchemesModel;
}
export class SecurityDefs extends React.PureComponent<SecurityDefsProps> {
render() {
if (!this.props.securitySchemes) {
return null;
}
return (
<div>
{this.props.securitySchemes.schemes.map(scheme => (

View File

@ -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": "<<<filtered>>>",
"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": "<<<filtered>>>",
"pattern": undefined,
"pointer": "#/components/schemas/Dog/properties/type",
"rawSchema": Object {
"default": undefined,
"type": "string",

View File

@ -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<StoreState> {
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<StoreState> {
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,
};
}
}

View File

@ -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<Referenced<OpenAPIParameter>>;
} & 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 || [],
});

View File

@ -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<GroupModel | OperationModel>;
/**
*
* @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() {

View File

@ -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();

View File

@ -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);
}
}

View File

@ -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;

View File

@ -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,
);
});
}
}

View File

@ -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<OpenAPISchema>,
$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) {

View File

@ -7,3 +7,4 @@ export * from './loadAndBundleSpec';
export * from './dom';
export * from './decorators';
export * from './debug';
export * from './memoize';

49
src/utils/memoize.ts Normal file
View File

@ -0,0 +1,49 @@
// source: https://github.com/andreypopp/memoize-decorator
const SENTINEL = {};
export function memoize<T>(target: any, name: string, descriptor: TypedPropertyDescriptor<T>) {
if (typeof descriptor.value === 'function') {
return (_memoizeMethod(target, name, descriptor) as any) as TypedPropertyDescriptor<T>;
} else if (typeof descriptor.get === 'function') {
return _memoizeGetter(target, name, descriptor) as TypedPropertyDescriptor<T>;
} 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<T>(target: any, name: string, descriptor: TypedPropertyDescriptor<T>) {
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;
},
};
}