@sap/cds-dk
Version:
Command line client and development toolkit for the SAP Cloud Application Programming Model
729 lines (648 loc) • 21.9 kB
JavaScript
const {
cdsName,
nameFromPath,
pathAndMethod,
serviceName,
} = require("./utilities");
module.exports = { importOpenAPI };
// we are not interested (yet) in HEAD, OPTIONS, TRACE
const IS_METHOD = {
delete: true,
get: true,
patch: true,
post: true,
put: true,
};
const STANDARD_HEADERS = [
"accept",
"accept-encoding",
"accept-language",
"authorization",
"content-type",
"if-match",
];
function importOpenAPI(input) {
const csn = {
definitions: {},
meta: { creator: "cds-import-openapi" },
};
const context = {
serviceName: serviceName(input.info?.title || "TODO.service"),
oasVersion: input.openapi || input.swagger,
v3: !!input.openapi,
schemas: input.openapi ? input.components?.schemas : input.definitions,
anonymous: [],
JSON: false,
};
//TODO: complain if neither swagger nor openapi3, i.e. context.oasVersion is undefined
const service = {
kind: "service",
"@Capabilities.BatchSupported": false,
"@Capabilities.KeyAsSegmentSupported": true,
};
if (input.info?.title) service["@Core.Description"] = input.info.title;
if (input.info?.version) service["@Core.SchemaVersion"] = input.info.version;
if (input.info?.description)
service["@Core.LongDescription"] = input.info.description;
csn.definitions[context.serviceName] = service;
operations(context, csn, input);
reuseTypes(context, csn);
anonymousTypes(context, csn);
return csn;
}
function reuseTypes(context, csn) {
for (const [name, schema] of Object.entries(context.schemas || {})) {
const typeName = `${context.serviceName}_types.${cdsName(name)}`;
csn.definitions[typeName] = cdsType(context, schema, false, true);
csn.definitions[typeName].kind = "type";
delete csn.definitions[typeName].notNull;
}
}
function anonymousTypes(context, csn) {
for (const a of context.anonymous) {
csn.definitions[a.name] = a.type;
csn.definitions[a.name].kind = "type";
}
if (context.JSON) {
csn.definitions["common.JSON"] = { kind: "type", type: "cds.LargeString" };
}
}
function operations(context, csn, input) {
const reuseParameters = context.v3
? input.components?.parameters
: input.parameters;
const reuseResponses = context.v3
? input.components?.responses
: input.responses;
for (const [path, item] of Object.entries(input.paths || {})) {
const globalParameters = item.parameters || [];
for (const [method, operation] of Object.entries(item)) {
if (!IS_METHOD[method]) continue;
const cdsOperation = {
kind: method === "get" ? "function" : "action",
params: {},
};
if (operation.tags) cdsOperation["@Common.Label"] = operation.tags[0];
if (operation.summary)
cdsOperation["@Core.Description"] = operation.summary;
if (operation.description)
cdsOperation["@Core.LongDescription"] = operation.description;
if (cdsOperation.kind === "action" && method !== "post")
cdsOperation["@openapi.method"] = method.toUpperCase();
cdsOperation["@openapi.path"] = path;
let bodyParam;
for (let param of globalParameters.concat(operation.parameters || [])) {
if (param.$ref) {
// resolve reuse parameter
const expectedPrefix = context.v3
? "#/components/parameters/"
: "#/parameters/";
if (!param.$ref.startsWith(expectedPrefix)) {
throw new Error(
`TODO: unexpected reference ${param.$ref} in parameter for ${method} of ${path}`,
);
}
param = reuseParameters[param.$ref.substring(expectedPrefix.length)];
}
if (param.in === "body") {
bodyParam = param;
continue;
}
if (STANDARD_HEADERS.includes(param.name.toLowerCase())) continue;
const cdsParam = cdsType(
context,
context.v3 ? param?.schema : param,
false,
false,
true,
);
if (param.description) cdsParam["@description"] = param.description;
cdsParam["@openapi.in"] = param.in;
// only add the annotation "@openapi.explode" for true scenario
if (param.explode || param.style === "form")
cdsParam["@openapi.explode"] = true;
if (!param.explode) delete cdsParam["@openapi.explode"];
if (
(param.in === "path" && param.style && param.style !== "simple") ||
(param.in === "query" && param.style && param.style !== "form")
)
cdsParam["@openapi.style"] = param.style;
if (param.in === "query" && param.collectionFormat === "ssv")
cdsParam["@openapi.style"] = "spaceDelimited";
if (param.in === "query" && param.collectionFormat === "pipes")
cdsParam["@openapi.style"] = "pipeDelimited";
if (param.in === "query" && param.allowReserved === true)
cdsParam["@openapi.allowReserved"] = true;
if (param.required && param.in !== "path")
cdsParam["@openapi.required"] = true;
if (!param.required) {
delete param.notNull;
if (param.default !== undefined)
cdsParam["default"] = { val: param.default };
}
const name = cdsName(param.name);
if (name !== param.name) cdsParam["@openapi.name"] = param.name;
cdsOperation.params[name] = cdsParam;
}
const schema = context.v3
? requestBodySchema(context, operation.requestBody, input.components)
: v2Schema(context, bodyParam?.schema, operation.consumes);
if (schema) {
cdsOperation.params.body = cdsType(context, schema);
cdsOperation.params.body["@openapi.in"] = "body";
}
if (!operation.responses)
throw new Error(
`TODO: no responses for path ${path}, method ${method}`,
);
const successCode = Object.keys(operation.responses || {}).find((r) =>
r.startsWith("2"),
);
if (successCode) {
let response = operation.responses[successCode];
if (response?.$ref) {
// resolve reuse response
const expectedPrefix = context.v3
? "#/components/responses/"
: "#/responses/";
if (!response.$ref.startsWith(expectedPrefix))
throw new Error(
`TODO: unexpected reference ${response.$ref} in ${successCode} response for method ${method} of path ${path}`,
);
else
response =
reuseResponses[response.$ref.substring(expectedPrefix.length)];
}
const responseSchema = context.v3
? v3Schema(context, response)
: v2Schema(context, response.schema, operation.produces);
if (responseSchema)
cdsOperation.returns = cdsType(context, responseSchema);
else if (method === "get")
cdsOperation.returns = { type: "cds.Boolean" };
} else if (method === "get")
cdsOperation.returns = { type: "cds.Boolean" };
const operationName = `${context.serviceName}.${nameFromPath(
path,
method,
)}`;
if (csn.definitions[operationName]) {
const existing = pathAndMethod(csn.definitions[operationName]);
throw new Error(
`Name collision: same name ${operationName} for method ${method} of path ${path} and method ${existing.method.toLowerCase()} of path ${existing.path
}`,
);
}
csn.definitions[operationName] = cdsOperation;
}
}
}
function requestBodySchema(context, requestBody, components) {
while (requestBody?.$ref) {
const expectedPrefix = "#/components/requestBodies/";
if (!requestBody.$ref.startsWith(expectedPrefix)) {
throw new Error(
`Unexpected request body reference ${requestBody.$ref}`,
);
} else {
requestBody =
components?.requestBodies[
requestBody.$ref.substring(expectedPrefix.length)
];
}
}
return v3Schema(context, requestBody);
}
function v3Schema(context, body) {
if (!body?.content) return undefined;
const contentTypes = Object.keys(body.content);
if (contentTypes.includes("application/json"))
return body.content["application/json"].schema;
if (contentTypes.length === 0) return undefined;
// if (contentTypes.length > 1)
// context.messages.push({
// message: `Multiple requestBody content-types not including application/json`,
// input: contentTypes,
// });
const contentType = contentTypes[0];
return {
$contentType: contentType,
...body.content[contentType]?.schema,
};
}
function v2Schema(context, schema, contentTypes) {
if (
!schema ||
!contentTypes ||
contentTypes.includes("application/json") ||
contentTypes.includes("application/json;charset=utf-8") ||
contentTypes.includes("application/json;charset=UTF-8")
)
return schema;
// take the first content type if there are multiple
const contentType = contentTypes[0];
return {
$contentType: contentType,
...schema,
};
}
function cdsType(
context,
schema,
arrayItem = false,
namedType = false,
forParameter = false,
) {
let type = {};
let hasIncludes = false;
if (!arrayItem) if (schema.title) type["@title"] = schema.title;
if (schema.description) type["@description"] = schema.description;
if (schema.$contentType && schema.$contentType !== "*/*")
type["@openapi.contentType"] = schema.$contentType;
if (schema.$ref) {
const refType = referencedType(context, schema.$ref);
let nRefType = refType;
if (refType.schema?.$ref)
nRefType = indirectlyReferencedType(context, refType.schema);
if (namedType && normalizeSchemaType(nRefType.schema) === "object") {
type.kind = "type";
type.includes = [refType.name];
type.elements = {};
} else {
type.type = refType.name;
if (schema.maxLength) type.length = schema.maxLength;
}
return type;
}
let schemaType = normalizeSchemaType(schema);
switch (schemaType) {
case "array":
//TODO: complain if "xml"
if (schema.items) {
const itemsType = cdsType(
context,
schema.items,
true,
false,
forParameter,
);
const annotations = Object.keys(itemsType).filter((k) =>
k.startsWith("@"),
);
// make anonymous type for item if
// - item has annotation
// - item has default
// - item is itself an array
if (annotations.length > 0 || itemsType.default || itemsType.items) {
type.items = anonymousType(context, itemsType);
} else {
type.items = itemsType;
}
} else {
type = someJSON(context, schema, arrayItem, type);
}
break;
case "boolean":
type.type = "cds.Boolean";
addDefault(type, schema, forParameter);
break;
case "file":
type.type = "cds.String";
break;
case "integer":
type.type = "cds.Integer";
if (schema.format === "int64") type.type = "cds.Integer64";
addDefault(type, schema, forParameter);
addPrimitiveExample(type, schema);
break;
case "number":
type.type = "cds.Decimal";
if (schema.format === "double" || schema.format === "float")
type.type = "cds.Double";
addDefault(type, schema, forParameter);
addPrimitiveExample(type, schema);
break;
case "object":
type.elements = {};
//TODO: discriminator(context, type, schema);
if (schema.anyOf) {
type["@openapi.anyOf"] = JSON.stringify(schema.anyOf);
type["@open"] = true;
}
if (schema.oneOf) {
type["@openapi.oneOf"] = JSON.stringify(schema.oneOf);
type["@open"] = true;
}
for (const subSchema of schema.allOf || []) {
if (subSchema.$ref) {
hasIncludes = true;
const refType = referencedType(context, subSchema.$ref);
if (!type.includes) type.includes = [];
type.includes.push(refType.name);
} else if (subSchema.type === "object" || subSchema.properties) {
//TODO: what if subSchema has allOf/...? Better recurse here and "merge" the types?
structElements(context, type, subSchema);
} else {
// should not get here
throw new Error(`Error: object with non-object sub-schema`);
}
}
structElements(context, type, schema);
if (!namedType && hasIncludes) {
type = anonymousType(context, type);
}
break;
case "string":
switch (schema.format) {
case "binary":
type.type = "cds.LargeBinary";
break;
case "date":
type.type = "cds.Date";
break;
case "date-time":
type.type = "cds.Timestamp";
break;
case "time":
type.type = "cds.Time";
break;
case "uuid":
type.type = "cds.UUID";
break;
default:
type.type = "cds.String";
if (schema.maxLength) type.length = schema.maxLength;
}
if (Array.isArray(schema.enum)) {
type["@assert.range"] = true;
type.enum = Object.fromEntries(
schema.enum
.filter((val) => val !== null)
.map((val) => {
const key = cdsName(val);
return key === val ? [val, {}] : [key, { val }];
}),
);
}
if (schema.pattern) type["@assert.format"] = schema.pattern;
addDefault(type, schema, forParameter);
addPrimitiveExample(type, schema);
break;
case undefined:
schemaType = bestMatchingType(context, schema);
if (
(!namedType || schemaType !== "object") &&
schema.allOf &&
schema.allOf.length === 1 &&
Object.keys(schema).filter(
(k) =>
!["description", "nullable"].includes(k) && !k.startsWith("x-"),
).length === 1
) {
const normalizedSchema = { ...schema.allOf[0] };
if (schema.description)
normalizedSchema.description = schema.description;
type = cdsType(
context,
normalizedSchema,
arrayItem,
namedType,
forParameter,
);
break;
}
if (
schema.allOf &&
schema.allOf.length === 2 &&
schema.allOf[0].$ref &&
!schema.allOf[1].$ref &&
normalizeSchemaType(schema.allOf[1]) !== "object"
) {
const normalizedSchema = Object.assign(
{ ...schema.allOf[0] },
schema.allOf[1],
);
type = cdsType(
context,
normalizedSchema,
arrayItem,
namedType,
forParameter,
);
break;
}
if (schemaType) {
schema.type = schemaType;
type = cdsType(context, schema, arrayItem, namedType, forParameter);
break;
}
// last resort
// eslint-disable-next-line no-fallthrough
default:
type = someJSON(context, schema, arrayItem, type);
break;
}
return type;
}
function someJSON(context, schema, arrayItem, type) {
context.JSON = true;
const jsonSchema = minimalSchema(schema);
if (arrayItem && jsonSchema !== "{}") {
type = anonymousType(context, {
type: "common.JSON",
"@openapi.schema": jsonSchema,
});
} else {
type.type = "common.JSON";
if (jsonSchema !== "{}") type["@openapi.schema"] = jsonSchema;
}
return type;
}
function addDefault(type, schema, forParameter) {
if (schema.default !== undefined) type.default = { val: schema.default };
if (forParameter) return;
}
function addPrimitiveExample(type, schema) {
const example = schema.examples?.[0] || schema.example;
if (!example || example.$ref) return;
type["@Core.Example.$Type"] = "Core.PrimitiveExampleValue";
type["@Core.Example.Value"] = example;
}
function minimalSchema(schema) {
const s = { ...schema };
delete s.title;
delete s.description;
delete s.$contentType;
return JSON.stringify(s);
}
function anonymousType(context, type) {
//TODO: construct newName from path leading here instead of using a counter
// pro: makes imported models more stable
// con: may cause name clashes
const newName = `${context.serviceName}.anonymous.type${context.anonymous.length}`;
context.anonymous.push({ name: newName, type: type });
return { type: newName };
}
function bestMatchingType(context, schema) {
if (schema.type) return schema.type;
let type;
if (schema.allOf || schema.anyOf || schema.oneOf) {
const xOf = (schema.allOf || []).concat(
schema.anyOf || [],
schema.oneOf || [],
);
for (const subSchema of xOf) {
// determine overall type of this construct
if (subSchema.$ref) {
const refType = indirectlyReferencedType(context, subSchema);
if (!refType.schema) return undefined;
if (
refType.schema.allOf ||
refType.schema.anyOf ||
refType.schema.oneOf
) {
const subType = bestMatchingType(context, refType.schema);
type = betterType(type, { type: subType });
} else type = betterType(type, refType.schema);
} else {
//TODO: nested xOf - not yet encountered
type = betterType(type, subSchema);
}
}
}
return type;
}
function indirectlyReferencedType(context, subSchema) {
let refType = { schema: subSchema };
let limit = 10;
while (refType.schema?.$ref && limit-- > 0)
refType = referencedType(context, refType.schema.$ref);
return refType;
}
function referencedType(context, ref) {
const expectedPrefix = context.v3
? "#/components/schemas/"
: "#/definitions/";
if (!ref.startsWith(expectedPrefix)) {
throw new Error(`TODO: unexpected reference ${ref} in schema.`);
}
const schemaName = decodeURIComponent(ref.substring(expectedPrefix.length));
const schema = context.schemas[schemaName];
return {
name: `${context.serviceName}_types.${cdsName(schemaName)}`,
schema,
};
}
function normalizeSchemaType(schema) {
if (!schema) return undefined;
if (!schema.type && schema.items) return "array";
if (!schema.type && schema.maxLength) return "string";
if (!schema.type && schema.pattern) return "string";
if (!schema.type && schema.enum?.every((v) => typeof v === "string"))
return "string";
if (
!schema.type &&
(schema.properties ||
schema.patternProperties ||
schema.additionalProperties ||
schema.discriminator)
)
return "object";
let schemaType = schema.type;
if (Array.isArray(schemaType)) {
schemaType = schemaType.sort();
if (schemaType.includes("null")) {
const index = schemaType.indexOf("null");
schemaType.splice(index, 1);
}
if (schemaType.length === 1) schemaType = schemaType[0];
if (
schemaType.length === 2 &&
schemaType[0] === "integer" &&
schemaType[1] === "number"
)
return "number";
}
return schemaType;
}
//TODO: better name :-)
function betterType(currentType, schema) {
const schemaType = normalizeSchemaType(schema);
if (currentType === undefined) return schemaType;
if (currentType === schemaType) return currentType;
if (currentType === "string" && ["integer", "number"].includes(schemaType))
return "string";
if (["integer", "number"].includes(currentType) && schemaType === "string")
return "string";
//TODO: more cases here?
return null;
}
// function discriminator(context, type, schema) {
// if (!schema.discriminator) return;
// console.log("hi");
// const propertyName =
// schema.discriminator.propertyName || schema.discriminator;
// type["@openapi.discriminator"] = { propertyName };
// if (schema.discriminator.mapping) {
// const mapping = {};
// for (const [value, reference] of Object.entries(
// schema.discriminator.mapping
// )) {
// mapping[value] = referencedType(context, reference).name;
// }
// type["@openapi.discriminator"].mapping = mapping;
// }
// }
function structElements(context, type, schema) {
checkCircularReference(schema);
const schemaName = Object.keys(context.schemas || {}).find(
(key) => context.schemas[key] === schema,
);
const refPath = `#/components/schemas/${schemaName}`;
//TODO: interpret "required"
for (const [prop, propSchema] of Object.entries(schema.properties || {})) {
const name = cdsName(prop);
//to detect recursive data types
if (propSchema?.$ref === refPath || propSchema?.items?.$ref === refPath) {
console.warn(
`Recursive data type detected: ${schemaName} for the property ${name}`,
);
}
//TODO: check if property "name" already exists and has identical cdsType, warn otherwise
type.elements[name] = cdsType(context, propSchema);
if (name !== prop) type.elements[name]["@openapi.name"] = prop;
}
}
// function to check circular references and throw error if found
function checkCircularReference(schema, visited = new Set()) {
if (schema.$ref) {
const ref = schema.$ref;
if (visited.has(ref)) {
console.warn(`Circular reference detected: ${ref}`);
}
visited.add(ref);
}
if (schema.allOf) {
schema.allOf.forEach((subSchema) =>
checkCircularReference(subSchema, visited),
);
}
if (schema.anyOf) {
schema.anyOf.forEach((subSchema) =>
checkCircularReference(subSchema, visited),
);
}
if (schema.oneOf) {
schema.oneOf.forEach((subSchema) =>
checkCircularReference(subSchema, visited),
);
}
if (schema.properties) {
Object.values(schema.properties).forEach((subSchema) =>
checkCircularReference(subSchema, visited),
);
}
if (schema.items) {
checkCircularReference(schema.items, visited);
}
}