diff --git a/packages/tspec/src/generator/jsonSchemaConverter.ts b/packages/tspec/src/generator/jsonSchemaConverter.ts new file mode 100644 index 0000000..600268c --- /dev/null +++ b/packages/tspec/src/generator/jsonSchemaConverter.ts @@ -0,0 +1,397 @@ +/* eslint-disable no-use-before-define */ +import debug from 'debug'; +import { OpenAPIV3 as oapi3 } from 'openapi-types'; +import * as tjs from 'typescript-json-schema'; + +import { isDefined } from '../utils/types'; + +import { Schema } from './types'; +import { + isNullableObject, + isObjectSchemaObject, + isReferenceObject, + isEmptyObject +} from '../utils/schema'; + + +export const isDefinitionBoolean = (defOrBool: tjs.DefinitionOrBoolean): defOrBool is boolean => { + if (defOrBool === true || defOrBool === false) { + return true; + } + return false; +}; + + +export const DEBUG = debug('tspec'); + +const createItem = (items: tjs.DefinitionOrBoolean[]) => { + let nullable = false; + const schema = items.map((item) => { + const convertedItem = convertDefinition(item); + if (convertedItem && isNullableObject(convertedItem)) { + nullable = true; + return undefined; + } + return convertedItem; + }); + + const nullableProperty = nullable ? { nullable } : {}; + + const filteredSchema = schema.filter(isDefined); + if (filteredSchema.length === 0) { + return nullableProperty; + } if (filteredSchema.length === 1) { + const onlySchema = filteredSchema[0]; + if (isReferenceObject(onlySchema) && nullable) { + return { + anyOf: [onlySchema, nullableProperty], + }; + } + return { + ...onlySchema, + ...nullableProperty, + }; + } if (filteredSchema.length > 1) { + return { + anyOf: filteredSchema, + ...nullableProperty, + }; + } +}; + +const convertItems = ( + items: tjs.DefinitionOrBoolean | tjs.DefinitionOrBoolean[], +) => { + if (!Array.isArray(items)) { + return convertDefinition(items); + } + + if (items.length === 1) { + return convertDefinition(items[0]); + } + + return createItem(items); +}; + +export const convertProperties = (obj: { + [key: string]: tjs.DefinitionOrBoolean, +}) => { + const convertedObj: { [key: string]: Schema } = {}; + for (const [key, val] of Object.entries(obj)) { + const convertedProperty = convertDefinition(val); + if (convertedProperty) { + convertedObj[key] = convertedProperty; + } + } + return convertedObj; +}; + +const convertSchemaArray = ( + defs: tjs.DefinitionOrBoolean[], + property: 'anyOf' | 'oneOf' | 'allOf', +) => { + let schema: Schema = {}; + let nullable = false; + + const object: Schema = { type: 'object', properties: {} }; + + const filteredDefs = defs.map((def) => { + const convertedDef = convertDefinition(def); + // undefined 제외 + if (!convertedDef) { + return undefined; + } + // {nullable: true}인 경우 nullable = true하고 제외 + if (isNullableObject(convertedDef)) { + nullable = true; + return undefined; + } + + if (property === 'allOf') { + // object의 proeprty 모아서 하나의 object로 만들기 + if (isObjectSchemaObject(convertedDef) && Object.keys(convertedDef.properties).length > 0) { + DEBUG(convertedDef); + object.properties = { + ...object.properties, + ...convertedDef.properties, + }; + return undefined; + } + } + + return convertedDef; + }); + + const convertedSchema = filteredDefs.filter(isDefined); + if (object.properties && Object.keys(object.properties).length > 0) { + convertedSchema.push(object); + } + + if (convertedSchema.length === 1) { + const onlySchema = convertedSchema[0]; + if (isReferenceObject(onlySchema) && nullable) { + // ReferenceObject는 다른 속성들과 함께 사용할 수 없음 + schema[property] = [onlySchema, { nullable }]; + } else { + schema = onlySchema; + } + } else if (convertedSchema.length > 1) { + schema[property] = convertedSchema; + } + + if (!isReferenceObject(schema)) { + if (nullable) { + schema.nullable = true; + } + } + + return schema; +}; +export const convertCombinedProperty = ( + def: tjs.Definition, +): Pick => { + const { + allOf, oneOf, anyOf, not, + } = def; + let schema: oapi3.BaseSchemaObject = {}; + + if (allOf) { + const convertedSchemaArray = convertSchemaArray(allOf, 'allOf'); + schema = { ...schema, ...convertedSchemaArray }; + } + + if (oneOf) { + const convertedSchemaArray = convertSchemaArray(oneOf, 'oneOf'); + schema = { ...schema, ...convertedSchemaArray }; + } + + if (anyOf) { + const convertedSchemaArray = convertSchemaArray(anyOf, 'anyOf'); + schema = { ...schema, ...convertedSchemaArray }; + } + + if (not) { + schema.not = convertDefinition(not); + } + + return schema; +}; + +export const extractCommonProperty = ( + def: tjs.Definition, +): Pick< + oapi3.BaseSchemaObject, + 'title' | 'enum' | 'example' | 'description' | 'format' | 'default' +> => { + const { + title, + enum: _enum, + examples, + description, + format, + default: _default, + } = def; + return { + title, + enum: _enum, + example: Array.isArray(examples) ? examples[0] : examples, + description, + format, + default: _default, + }; +}; + +const covertToBooleanSchemaObject = (): oapi3.SchemaObject => ({ + type: 'boolean', +}); + +const covertToNumberSchemaObject = ( + def: tjs.Definition, + type: 'number' | 'integer' = 'number', +): oapi3.SchemaObject => { + const { + multipleOf, maximum, exclusiveMaximum, exclusiveMinimum, minimum, + } = def; + return { + type, + multipleOf, + maximum: maximum !== undefined ? maximum : exclusiveMaximum, + exclusiveMaximum: exclusiveMaximum !== undefined ? true : undefined, + minimum: minimum !== undefined ? minimum : exclusiveMinimum, + exclusiveMinimum: exclusiveMinimum !== undefined ? true : undefined, + }; +}; + +const covertToStringSchemaObject = ( + def: tjs.Definition, +): oapi3.SchemaObject => { + const { maxLength, minLength, pattern } = def; + + if (pattern) { + const isValidRegExp = RegExp.prototype.test(pattern); + if (!isValidRegExp) { + throw Error(`${pattern} is not valid RegExp`); + } + } + + return { + type: 'string', + maxLength, + minLength, + }; +}; + +const covertToArraySchemaObject = ( + def: tjs.Definition, +): oapi3.ArraySchemaObject => { + const { + items, maxItems, minItems, uniqueItems, + } = def; // additionalItems, contains 제외 + + const convertedItems = items ? convertItems(items) : undefined; + + if (!convertedItems) { + throw Error('array type need items'); + } + + return { + type: 'array', + items: convertedItems, + maxItems, + minItems, + uniqueItems, + }; +}; + +const covertToObjectSchemaObject = ( + def: tjs.Definition, +): oapi3.SchemaObject => { + const commonSchema = extractCommonProperty(def); + + const { + maxProperties, + minProperties, + required, + properties, + additionalProperties, + } = def; + + const convertedAdditionalProperties = additionalProperties + ? convertDefinition(additionalProperties) + : undefined; + const convertedProperties = properties + ? convertProperties(properties) + : undefined; + + return { + type: 'object', + maxProperties, + minProperties, + required, + properties: convertedProperties, + additionalProperties: convertedAdditionalProperties, + ...commonSchema, + }; +}; + +const convertSchemaObjectByType = (type: string, def: tjs.Definition) => { + if (type === 'number' || type === 'integer') { + return covertToNumberSchemaObject(def, type); + } if (type === 'string') { + return covertToStringSchemaObject(def); + } if (type === 'object') { + return covertToObjectSchemaObject(def); + } if (type === 'array') { + return covertToArraySchemaObject(def); + } if (type === 'boolean') { + return covertToBooleanSchemaObject(); + } + return { nullable: true }; +}; + +const convertType = ( + def: tjs.Definition, + commonSchema: Schema, +): Schema => { + const types = def.type + ? Array.isArray(def.type) + ? def.type + : [def.type] + : []; + + let nullable = false; + + const splitedSchemas = types.map((type) => { + if (type === 'null') { + nullable = true; + return undefined; + } + const ret = convertSchemaObjectByType(type, def); + return ret; + }); + + const nullableProperty = nullable ? { nullable } : {}; + + const refinedSchemas = splitedSchemas + .filter(isDefined) + .map((schema) => ({ ...schema, ...commonSchema })); // 모든 property는 동시에 만족해야함 + + const referenceObject = def.$ref + ? { + $ref: def.$ref.replace(/[^A-Za-z0-9_.-]/g, '_').replace( + /(__definitions_)(\w)/, + '#/components/schemas/$2', + ), + } + : undefined; + if (referenceObject) { + refinedSchemas.push(referenceObject); + } + + if (refinedSchemas.length === 0) { + return { + ...commonSchema, + ...nullableProperty, + }; + } if (refinedSchemas.length === 1) { + const onlySchema = refinedSchemas[0]; + + if (onlySchema && isReferenceObject(onlySchema)) { + const baseSchema = { ...commonSchema, ...nullableProperty }; + if (!isEmptyObject(baseSchema)) { + // reference object는 baseSchema랑 같이 사용할 수 없음 + return { + allOf: [baseSchema, onlySchema], + }; + } + return onlySchema; + } + + return { + ...onlySchema, + ...nullableProperty, + }; + } + return { + anyOf: refinedSchemas, + ...nullableProperty, + }; +}; + +export const convertDefinition = ( + def: tjs.DefinitionOrBoolean, +): Schema | undefined => { + if (isDefinitionBoolean(def)) { + return undefined; + } + + const commonProperty = extractCommonProperty(def); + const combinedProperty = convertCombinedProperty(def); + + const commonSchema = { + ...commonProperty, + ...combinedProperty, + }; + + return convertType(def, commonSchema); +}; diff --git a/packages/tspec/src/generator/openapiSchemaConverter.ts b/packages/tspec/src/generator/openapiSchemaConverter.ts index 060f048..c88b4f8 100644 --- a/packages/tspec/src/generator/openapiSchemaConverter.ts +++ b/packages/tspec/src/generator/openapiSchemaConverter.ts @@ -1,6 +1,5 @@ -import convert from 'json-schema-to-openapi-schema'; // TODO: 이게 정말 필요한건지 체크 필요. import * as TJS from 'typescript-json-schema'; - +import { convertDefinition } from './jsonSchemaConverter' import { SchemaMapping } from './types'; const isSchemaNullableOnly = (s: any) => ( @@ -141,6 +140,6 @@ export const convertToOpenapiSchemas = async ( jsonSchemas: TJS.Definition, ): Promise => { const convertedJsonSchemas = convertToOpenapiTypes(jsonSchemas); - const openapiSchemas = await convert(convertedJsonSchemas) as SchemaMapping; + const openapiSchemas = convertDefinition(convertedJsonSchemas) as SchemaMapping; return escapeSchemaNames(openapiSchemas); }; diff --git a/packages/tspec/src/generator/types.ts b/packages/tspec/src/generator/types.ts index 23e7fe5..851469b 100644 --- a/packages/tspec/src/generator/types.ts +++ b/packages/tspec/src/generator/types.ts @@ -2,3 +2,26 @@ import { OpenAPIV3 } from 'openapi-types'; export type Schema = OpenAPIV3.SchemaObject | OpenAPIV3.ReferenceObject; export type SchemaMapping = Record; + +export interface NumberSchemaObject extends OpenAPIV3.NonArraySchemaObject { + type: 'number', +} + +export interface StringSchemaObject extends OpenAPIV3.NonArraySchemaObject { + type: 'string', +} + +export interface IntegerSchemaObject extends OpenAPIV3.NonArraySchemaObject { + type: 'integer', +} + +export interface BooleanSchemaObject extends OpenAPIV3.NonArraySchemaObject { + type: 'boolean', +} + +export interface ObjectSchemaObject extends OpenAPIV3.NonArraySchemaObject { + type: 'object', + properties: { + [name: string]: Schema, + }, +} diff --git a/packages/tspec/src/utils/schema.ts b/packages/tspec/src/utils/schema.ts new file mode 100644 index 0000000..8635f13 --- /dev/null +++ b/packages/tspec/src/utils/schema.ts @@ -0,0 +1,112 @@ +import { + BooleanSchemaObject, IntegerSchemaObject, NumberSchemaObject, ObjectSchemaObject, Schema, StringSchemaObject, +} from 'generator/types'; +import { OpenAPIV3 } from 'openapi-types'; +import { DefinitionOrBoolean } from 'typescript-json-schema'; + +export const isDefinitionBoolean = (defOrBool: DefinitionOrBoolean): defOrBool is boolean => { + if (defOrBool === true || defOrBool === false) { + return true; + } + return false; +}; + +export const isReferenceObject = ( + schema: Schema, +): schema is OpenAPIV3.ReferenceObject => { + // eslint-disable-next-line no-prototype-builtins + if (Object(schema).hasOwnProperty('$ref')) { + return true; + } + return false; +}; + +export const isIntegerSchemaObject = ( + schema: Schema, +): schema is IntegerSchemaObject => { + if (isReferenceObject(schema)) { + return false; + } + if (schema.type === 'integer') { + return true; + } + return false; +}; + +export const isNumberSchemaObject = ( + schema: Schema, +): schema is NumberSchemaObject => { + if (isReferenceObject(schema)) { + return false; + } + if (schema.type === 'number') { + return true; + } + return false; +}; + +export const isBooleanSchemaObject = ( + schema: Schema, +): schema is BooleanSchemaObject => { + if (isReferenceObject(schema)) { + return false; + } + if (schema.type === 'boolean') { + return true; + } + return false; +}; + +export const isStringSchemaObject = ( + schema: Schema, +): schema is StringSchemaObject => { + if (isReferenceObject(schema)) { + return false; + } + if (schema.type === 'string') { + return true; + } + return false; +}; + +export const isObjectSchemaObject = ( + schema: Schema, +): schema is ObjectSchemaObject => { + if (isReferenceObject(schema)) { + return false; + } + if (schema.type === 'object' && schema.properties !== undefined) { + return true; + } + return false; +}; + +export const isArraySchemaObject = ( + schema: OpenAPIV3.SchemaObject, +): schema is OpenAPIV3.ArraySchemaObject => { + if (isReferenceObject(schema)) { + return false; + } + if (schema.type === 'array' && schema.items !== undefined) { + return true; + } + return false; +}; + +export const isNullableObject = (schema: Schema) => { + if (isReferenceObject(schema)) { + return false; + } + if (Object.keys(schema).length === 1 && schema.nullable === true) { + return true; + } + return false; +}; + +export function isEmptyObject(obj: Object) { + if (Object.keys(obj).length === 0) { + return true; + } + + return Object.values(obj).every((v) => v === undefined); +}