298 lines
11 KiB
Plaintext
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;
|
|
}
|
|
|
|
}
|