Files
svc-api-x/v1/taffy/core/openapi.cfc

298 lines
11 KiB
Plaintext

component output="false" hint="Generates an OpenAPI 3.1 document from Taffy runtime metadata." {
public struct function generate()
hint="Returns an OpenAPI 3.1 struct describing all registered resources." {
var settings = application._taffy.settings;
var endpoints = application._taffy.endpoints;
var matchOrder = application._taffy.uriMatchOrder;
var openapiCfg = structKeyExists(settings, "openapi") ? settings.openapi : {};
var mediaTypes = collectResponseMediaTypes();
var doc = {
"openapi": "3.1.0",
"info": buildInfo(settings, openapiCfg),
"paths": {}
};
var servers = buildServers(openapiCfg);
if (arrayLen(servers)) {
doc["servers"] = servers;
}
for (var i = 1; i <= arrayLen(matchOrder); i++) {
var endpoint = endpoints[matchOrder[i]];
var bean = application._taffy.factory.getBean(endpoint.beanName);
var beanMeta = getMetaData(bean);
if (hasAnyKey(beanMeta, "taffy_docs_hide,taffy:docs:hide,taffy_dashboard_hide,taffy:dashboard:hide")) {
continue;
}
var funcs = collectFunctions(beanMeta);
var pathItem = {};
var validVerbs = "get,put,post,delete,options,head,patch,trace";
for (var verb in endpoint.methods) {
if (!listFindNoCase(validVerbs, verb)) continue;
var funcName = endpoint.methods[verb];
if (!structKeyExists(funcs, funcName)) continue;
var func = funcs[funcName];
if (hasAnyKey(func, "taffy_docs_hide,taffy:docs:hide,taffy_dashboard_hide,taffy:dashboard:hide")) continue;
pathItem[lcase(verb)] = buildOperation(verb, func, endpoint, beanMeta, mediaTypes);
}
if (!structIsEmpty(pathItem)) {
doc.paths[normalizePathTemplate(endpoint.srcUri, endpoint.tokens)] = pathItem;
}
}
return doc;
}
private struct function buildInfo(required struct settings, required struct openapiCfg) {
var info = {
"title": arguments.settings.docs.APIName,
"version": arguments.settings.docs.APIVersion
};
if (structKeyExists(arguments.openapiCfg, "description")) info["description"] = arguments.openapiCfg.description;
if (structKeyExists(arguments.openapiCfg, "contact")) info["contact"] = arguments.openapiCfg.contact;
if (structKeyExists(arguments.openapiCfg, "license")) info["license"] = arguments.openapiCfg.license;
return info;
}
private array function buildServers(required struct openapiCfg) {
if (structKeyExists(arguments.openapiCfg, "servers") && isArray(arguments.openapiCfg.servers)) {
return arguments.openapiCfg.servers;
}
var host = cgi.http_host;
if (!len(host)) return [];
var isHttps = cgi.server_port == 443 || (structKeyExists(cgi, "https") && cgi.https == "on");
var scheme = isHttps ? "https" : "http";
var scriptDir = getDirectoryFromPath(cgi.script_name);
if (right(scriptDir, 1) == "/" && len(scriptDir) > 1) {
scriptDir = left(scriptDir, len(scriptDir) - 1);
}
return [ { "url": scheme & "://" & host & scriptDir } ];
}
private array function collectResponseMediaTypes()
hint="Unique MIME type strings Taffy can return, derived from registered serializers." {
var mimes = {};
for (var ext in application._taffy.settings.mimeExtensions) {
for (var part in listToArray(application._taffy.settings.mimeExtensions[ext], ";")) {
mimes[trim(part)] = true;
}
}
var out = [];
for (var m in mimes) arrayAppend(out, m);
arraySort(out, "text");
return out;
}
private struct function collectFunctions(required struct metadata)
hint="Flatten the cfc's function metadata (including inherited) keyed by name. Child overrides parent." {
var result = {};
if (structKeyExists(arguments.metadata, "extends") && arguments.metadata.extends.fullname != "taffy.core.resource") {
result = collectFunctions(arguments.metadata.extends);
}
if (!structKeyExists(arguments.metadata, "functions") || !isArray(arguments.metadata.functions)) {
return result;
}
for (var f = 1; f <= arrayLen(arguments.metadata.functions); f++) {
var func = arguments.metadata.functions[f];
if (structKeyExists(func, "access") && (func.access == "private" || func.access == "package")) continue;
result[func.name] = func;
}
return result;
}
private struct function buildOperation(
required string verb,
required struct func,
required struct endpoint,
required struct beanMeta,
required array mediaTypes
) {
var op = {
"tags": [ getResourceTag(arguments.beanMeta, arguments.endpoint.beanName) ],
"operationId": arguments.endpoint.beanName & "_" & lcase(arguments.verb)
};
if (structKeyExists(arguments.beanMeta, "taffy:docs:name")) {
op["summary"] = arguments.beanMeta["taffy:docs:name"];
} else if (structKeyExists(arguments.beanMeta, "taffy_docs_name")) {
op["summary"] = arguments.beanMeta["taffy_docs_name"];
}
if (structKeyExists(arguments.func, "hint")) op["description"] = arguments.func.hint;
var tokenLookup = {};
for (var t = 1; t <= arrayLen(arguments.endpoint.tokens); t++) {
tokenLookup[lcase(arguments.endpoint.tokens[t])] = arguments.endpoint.tokens[t];
}
var parameters = [];
var bodyParams = [];
var declaredPathTokens = {};
var params = structKeyExists(arguments.func, "parameters") ? arguments.func.parameters : [];
var isBodyVerb = listFindNoCase("post,put,patch", arguments.verb) > 0;
for (var p = 1; p <= arrayLen(params); p++) {
var param = params[p];
if (hasAnyKey(param, "taffy_docs_hide,taffy:docs:hide,taffy_dashboard_hide,taffy:dashboard:hide")) continue;
if (structKeyExists(tokenLookup, lcase(param.name))) {
// use the token's original casing so the parameter name matches the path template exactly
arrayAppend(parameters, buildParameter(param, "path", true, tokenLookup[lcase(param.name)]));
declaredPathTokens[lcase(param.name)] = true;
} else if (isBodyVerb) {
arrayAppend(bodyParams, param);
} else {
var required = structKeyExists(param, "required") && param.required;
arrayAppend(parameters, buildParameter(param, "query", required));
}
}
// backfill any path tokens the resource didn't declare as args — OpenAPI requires a parameter for every {token}
for (var t2 = 1; t2 <= arrayLen(arguments.endpoint.tokens); t2++) {
var tok = arguments.endpoint.tokens[t2];
if (!structKeyExists(declaredPathTokens, lcase(tok))) {
arrayAppend(parameters, {
"name": tok,
"in": "path",
"required": true,
"schema": { "type": "string" }
});
}
}
if (arrayLen(parameters)) op["parameters"] = parameters;
if (isBodyVerb && arrayLen(bodyParams)) op["requestBody"] = buildRequestBody(bodyParams);
op["responses"] = buildResponses(arguments.mediaTypes);
return op;
}
private struct function buildParameter(required struct param, required string location, required boolean required, string nameOverride = "") {
var out = {
"name": len(arguments.nameOverride) ? arguments.nameOverride : arguments.param.name,
"in": arguments.location,
"required": arguments.required,
"schema": paramSchema(arguments.param)
};
if (structKeyExists(arguments.param, "hint")) out["description"] = arguments.param.hint;
return out;
}
private struct function buildRequestBody(required array bodyParams) {
var properties = {};
var required = [];
for (var p = 1; p <= arrayLen(arguments.bodyParams); p++) {
var param = arguments.bodyParams[p];
var schema = paramSchema(param);
if (structKeyExists(param, "hint")) schema["description"] = param.hint;
properties[param.name] = schema;
if (structKeyExists(param, "required") && param.required) arrayAppend(required, param.name);
}
var bodySchema = { "type": "object", "properties": properties };
if (arrayLen(required)) bodySchema["required"] = required;
return {
"content": {
"application/json": { "schema": bodySchema },
"application/x-www-form-urlencoded": { "schema": bodySchema }
}
};
}
private struct function buildResponses(required array mediaTypes) {
var content = {};
for (var i = 1; i <= arrayLen(arguments.mediaTypes); i++) {
content[arguments.mediaTypes[i]] = {};
}
var ok = { "description": "Successful response" };
if (!structIsEmpty(content)) ok["content"] = content;
return { "200": ok, "default": { "description": "Unexpected error" } };
}
private struct function paramSchema(required struct param) {
var schema = cfmlTypeToSchema(structKeyExists(arguments.param, "type") ? arguments.param.type : "");
if (structKeyExists(arguments.param, "default") && len(arguments.param.default)) {
schema["default"] = arguments.param.default;
}
return schema;
}
private struct function cfmlTypeToSchema(required string type) {
switch (lcase(trim(arguments.type))) {
case "numeric": case "number": case "double": case "float":
return { "type": "number" };
case "integer":
return { "type": "integer" };
case "string":
return { "type": "string" };
case "boolean":
return { "type": "boolean" };
case "struct":
return { "type": "object" };
case "array":
return { "type": "array" };
case "date": case "datetime":
return { "type": "string", "format": "date-time" };
case "uuid": case "guid":
return { "type": "string", "format": "uuid" };
case "email":
return { "type": "string", "format": "email" };
case "url":
return { "type": "string", "format": "uri" };
case "": case "any": case "variablename":
return {};
default:
return { "type": "string" };
}
}
private string function getResourceTag(required struct beanMeta, required string beanName) {
if (structKeyExists(arguments.beanMeta, "taffy:docs:name")) return arguments.beanMeta["taffy:docs:name"];
if (structKeyExists(arguments.beanMeta, "taffy_docs_name")) return arguments.beanMeta["taffy_docs_name"];
return arguments.beanName;
}
private string function normalizePathTemplate(required string uri, required array tokens)
hint="Rebuild Taffy's URI as an OpenAPI path template: {name:regex} => {name}. Handles nested braces in token regexes." {
var out = "";
var i = 1;
var tokenIdx = 1;
var n = len(arguments.uri);
while (i <= n) {
var ch = mid(arguments.uri, i, 1);
if (ch == "{") {
// find the matching close brace, honoring nesting
var depth = 1;
var j = i + 1;
while (j <= n && depth > 0) {
var c2 = mid(arguments.uri, j, 1);
if (c2 == "{") depth++;
else if (c2 == "}") depth--;
if (depth > 0) j++;
}
if (depth == 0 && tokenIdx <= arrayLen(arguments.tokens)) {
out &= "{" & arguments.tokens[tokenIdx] & "}";
tokenIdx++;
i = j + 1;
continue;
}
}
out &= ch;
i++;
}
return out;
}
private boolean function hasAnyKey(required struct target, required string keys) {
for (var k in listToArray(arguments.keys)) {
if (structKeyExists(arguments.target, k)) return true;
}
return false;
}
}