1 module openapi_client.paths; 2 3 import vibe.data.json : Json, deserializeJson; 4 import vibe.core.log : logDebug; 5 6 import std.container.rbtree : RedBlackTree; 7 import std.file : mkdir, mkdirRecurse, write; 8 import std.array : appender, split, Appender, join; 9 import std.algorithm : skipOver; 10 import std.path : buildNormalizedPath, dirName; 11 import std.string : tr, capitalize, indexOf; 12 import std.range : tail, takeOne; 13 import std.stdio : writeln; 14 import std.regex : regex, replaceAll; 15 import std.conv : to; 16 17 import openapi : OasDocument, OasPathItem, OasOperation, OasParameter, OasMediaType, OasRequestBody, OasResponse; 18 import openapi_client.schemas; 19 import openapi_client.util : toUpperCamelCase, toLowerCamelCase, wordWrapText, writeCommentBlock; 20 21 struct PathEntry { 22 string path; 23 OasPathItem pathItem; 24 } 25 26 /** 27 * Given the paths in an OpenApi Specification Document, produce D-language code that can perform 28 * REST requests to communicate with the API. Depending on the architecture concepts being used, 29 * these files are the equivalent to a "service" or a "gateway" class. 30 * 31 * See_Also: https://swagger.io/specification/#paths-object 32 */ 33 void writePathFiles(OasDocument oasDocument, string targetDir, string packageRoot) { 34 // Rather than giving every path a separate class, group them by common URLs. 35 PathEntry[][string] pathEntriesByPathRoot; 36 foreach (string path, OasPathItem pathItem; oasDocument.paths) { 37 writeln("Adding path: ", path); 38 // Grouping API endpoints up until the first path parameter strikes a reasonable balance. 39 auto re = regex(r"(/\{[^{}]*\}.*)|(/$)", "g"); 40 string pathRoot = replaceAll(path, re, ""); 41 writeln(" PathRoot: ", pathRoot); 42 pathEntriesByPathRoot[pathRoot] ~= PathEntry(path, pathItem); 43 } 44 45 // Now we can write methods from the grouped PathItem objects into their class. 46 foreach (string pathRoot, PathEntry[] pathEntries; pathEntriesByPathRoot) { 47 writeln("Generating service for ", pathRoot, " with ", pathEntries.length, " path items."); 48 auto buffer = appender!string(); 49 string moduleName = pathRoot[1..$].tr("-/", "_") ~ "_service"; 50 generateModuleHeader(buffer, packageRoot, moduleName); 51 generateModuleImports(buffer, pathEntries, packageRoot); 52 // TODO: Generate imports that originate from the data types created here. 53 buffer.put("/**\n"); 54 buffer.put(" * Service to make REST API calls to paths beginning with: " ~ pathRoot ~ "\n"); 55 buffer.put(" */\n"); 56 string className = moduleName.toUpperCamelCase(); 57 buffer.put("class " ~ className ~ " {\n"); 58 foreach (PathEntry pathEntry; pathEntries) { 59 writeln(" - Generating methods for ", pathEntry.path); 60 generatePathItemMethods(buffer, pathEntry.path, pathEntry.pathItem); 61 } 62 generateModuleFooter(buffer); 63 64 string fileName = 65 buildNormalizedPath(targetDir, tr(packageRoot ~ ".service." ~ moduleName, ".", "/") ~ ".d"); 66 writeln("Writing file: ", fileName); 67 mkdirRecurse(dirName(fileName)); 68 write(fileName, buffer[]); 69 } 70 } 71 72 /** 73 * Dive through the PathEntries and extract a list of needed imports. 74 */ 75 void generateModuleImports(Appender!string buffer, PathEntry[] pathEntries, string packageRoot) { 76 RedBlackTree!string refs = new RedBlackTree!string(); 77 foreach (PathEntry pathEntry; pathEntries) { 78 foreach (OperationEntry entry; getPathItemOperationEntries(pathEntry.pathItem)) { 79 if (entry.operation is null) 80 continue; 81 // Add any types connected to path/header/query/cookie parameters. 82 foreach (OasParameter parameter; entry.operation.parameters) { 83 getSchemaReferences(parameter.schema, refs); 84 } 85 // Add any types connected to the request. 86 OasRequestBody requestBody = entry.operation.requestBody; 87 if (requestBody !is null) { 88 OasMediaType mediaType; 89 foreach (pair; requestBody.content.byKeyValue()) { 90 mediaType = pair.value; 91 } 92 if (mediaType.schema !is null) 93 getSchemaReferences(mediaType.schema, refs); 94 } 95 // Add any types connected to the response. 96 foreach (pair; entry.operation.responses.byKeyValue()) { 97 // HTTP status code = pair.key 98 OasResponse response = pair.value; 99 foreach (mediaEntry; response.content.byKeyValue()) { 100 // HTTP content type = mediaEntry.key 101 OasMediaType mediaType = mediaEntry.value; 102 if (mediaType.schema !is null) 103 getSchemaReferences(mediaType.schema, refs); 104 } 105 } 106 } 107 } 108 109 // Add imports for any referenced schemas. 110 with (buffer) { 111 foreach (string schemaRef; refs) { 112 string schemaName = getSchemaNameFromRef(schemaRef); 113 put("public import "); 114 put(getModuleNameFromSchemaName(packageRoot, schemaName)); 115 put(" : "); 116 put(getClassNameFromSchemaName(schemaName)); 117 put(";\n"); 118 } 119 put("\n"); 120 } 121 } 122 123 /** 124 * Writes the beginning of a class file for a service that can access a REST API. 125 */ 126 void generateModuleHeader( 127 Appender!string buffer, string packageRoot, string moduleName) { 128 with (buffer) { 129 put("// File automatically generated from OpenAPI spec.\n"); 130 put("module " ~ packageRoot ~ ".service." ~ moduleName ~ ";\n"); 131 put("\n"); 132 put("import vibe.http.client : requestHTTP, HTTPClientRequest, HTTPClientResponse;\n"); 133 put("import vibe.http.common : HTTPMethod;\n"); 134 put("import vibe.stream.operations : readAllUTF8;\n"); 135 put("import vibe.data.serialization : vibeName = name, vibeOptional = optional, vibeEmbedNullable = embedNullable;\n"); 136 put("import vibe.data.json : Json, deserializeJson;\n"); 137 put("import builder : AddBuilder;\n"); 138 put("\n"); 139 put("import " ~ packageRoot ~ ".servers : Servers;\n"); 140 put("import " ~ packageRoot ~ ".security : Security;\n"); 141 put("import openapi_client.util : isNull;\n"); 142 put("import openapi_client.apirequest : ApiRequest;\n"); 143 put("import openapi_client.handler : ResponseHandler;\n"); 144 put("\n"); 145 put("import std.conv : to;\n"); 146 put("import std.typecons : Nullable;\n"); 147 put("import std.stdio;\n"); 148 put("\n"); 149 } 150 } 151 152 struct OperationEntry { 153 string method; 154 OasOperation operation; 155 } 156 157 OperationEntry[] getPathItemOperationEntries(OasPathItem pathItem) { 158 return [ 159 OperationEntry("GET", pathItem.get), 160 OperationEntry("PUT", pathItem.put), 161 OperationEntry("POST", pathItem.post), 162 OperationEntry("DELETE", pathItem.delete_), 163 OperationEntry("OPTIONS", pathItem.options), 164 OperationEntry("HEAD", pathItem.head), 165 OperationEntry("PATCH", pathItem.patch), 166 OperationEntry("TRACE", pathItem.trace), 167 ]; 168 } 169 170 void generatePathItemMethods( 171 Appender!string buffer, string path, OasPathItem pathItem, string prefix = " ") { 172 OperationEntry[] operationEntries = getPathItemOperationEntries(pathItem); 173 with (buffer) { 174 foreach (OperationEntry operationEntry; operationEntries) { 175 if (operationEntry.operation is null) 176 continue; 177 string requestParamType = 178 generateRequestParamType(buffer, operationEntry, prefix); 179 // The request body type might need to be defined, so that it may be used as an argument to 180 // the function that actually performs the request. 181 RequestBodyType requestBodyType = 182 generateRequestBodyType(buffer, operationEntry, prefix); 183 184 ResponseHandlerType responseHandlerType = 185 generateResponseHandlerType(buffer, operationEntry, prefix); 186 187 // The documentation is the same for all methods for a given path. 188 writeCommentBlock( 189 buffer, 190 join( 191 [ 192 pathItem.summary, 193 pathItem.description, 194 operationEntry.operation.summary, 195 operationEntry.operation.description, 196 "See_Also: HTTP " ~ operationEntry.method ~ " `" ~ path ~ "`" 197 ], 198 "\n\n"), 199 prefix, 200 100); 201 put(prefix ~ "void " ~ operationEntry.operation.operationId.toLowerCamelCase() ~ "(\n"); 202 203 // Put the parameters as function arguments. 204 if (requestParamType !is null) { 205 put(prefix ~ " "); 206 put(requestParamType); 207 put(" params,\n"); 208 } 209 210 // Put the requestBody (if present) argument. 211 if (requestBodyType !is null) { 212 put(prefix ~ " "); 213 put(requestBodyType.codeType ~ " requestBody,\n"); 214 } 215 216 // Put the responseHandler (if present) argument. 217 if (responseHandlerType !is null) { 218 put(prefix ~ " "); 219 put(responseHandlerType.codeType ~ " responseHandler,\n"); 220 } 221 222 put(prefix ~ " ) {\n"); 223 put(prefix ~ " ApiRequest requestor = new ApiRequest(\n"); 224 put(prefix ~ " HTTPMethod." ~ operationEntry.method ~ ",\n"); 225 put(prefix ~ " " ~ (pathItem.servers !is null 226 ? "\"" ~ pathItem.servers[0].url ~ "\"" : "Servers.getServerUrl()") ~ ",\n"); 227 put(prefix ~ " \"" ~ path ~ "\");\n"); 228 foreach (OasParameter parameter; operationEntry.operation.parameters) { 229 string setterMethod; 230 if (parameter.in_ == "query") { 231 // TODO: Support other encoding mechanisms rather than assuming "deepObject". 232 setterMethod = "setQueryParam!(\"deepObject\")"; 233 } else if (parameter.in_ == "header") { 234 setterMethod = "setHeaderParam"; 235 } else if (parameter.in_ == "path") { 236 setterMethod = "setPathParam"; 237 } else if (parameter.in_ == "cookie") { 238 setterMethod = "setCookieParam"; 239 } 240 put(prefix ~ " if (!params." ~ getVariableName(parameter.name) ~ ".isNull)\n"); 241 put(prefix ~ " requestor." ~ setterMethod ~ "(\"" ~ parameter.name ~ "\", params." 242 ~ getVariableName(parameter.name) ~ ");\n"); 243 } 244 // Don't forget to set the content-type of the requestBody. 245 if (requestBodyType !is null) { 246 put(prefix ~ " requestor.setHeaderParam(\"Content-Type\", \"" 247 ~ requestBodyType.contentType ~ "\");\n"); 248 } 249 // The security policy may modify the request as well. 250 put(prefix ~ " Security.apply(requestor);\n"); 251 // Finally let the request execute. 252 put(prefix ~ " requestor.makeRequest("); 253 if (requestBodyType is null) 254 put("null"); 255 else 256 put("requestBody"); 257 put(", responseHandler);\n"); 258 put(prefix ~ "}\n\n"); 259 } 260 } 261 } 262 263 /** 264 * Information about the request body for an [OasOperation]. 265 */ 266 class RequestBodyType { 267 /** 268 * The type in D-code representing the request body. 269 */ 270 string codeType; 271 string contentType; 272 OasMediaType mediaType; 273 } 274 275 /** 276 * Determine what type the RequestBody is for a request, and if needed, generated. 277 */ 278 RequestBodyType generateRequestBodyType( 279 Appender!string buffer, OperationEntry operationEntry, string prefix = " ") { 280 if (operationEntry.operation.requestBody is null) 281 return null; 282 OasRequestBody requestBody = operationEntry.operation.requestBody; 283 284 string contentType; 285 OasMediaType mediaType; 286 // Take the first defined content type, it is unclear how to resolve multiple types. 287 foreach (pair; requestBody.content.byKeyValue()) { 288 contentType = pair.key; 289 mediaType = pair.value; 290 break; 291 } 292 293 // TODO: Figure out what to do with `mediaType.encoding` 294 295 string defaultRequestBodyTypeName = operationEntry.operation.operationId ~ "Body"; 296 RequestBodyType requestBodyType = new RequestBodyType(); 297 requestBodyType.contentType = contentType; 298 requestBodyType.codeType = getSchemaCodeType(mediaType.schema, defaultRequestBodyTypeName); 299 requestBodyType.mediaType = mediaType; 300 if (requestBodyType.codeType is null) 301 return null; 302 303 generateSchemaInnerClasses(buffer, mediaType.schema, prefix, defaultRequestBodyTypeName); 304 305 return requestBodyType; 306 } 307 308 void generateModuleFooter(Appender!string buffer) { 309 buffer.put(" mixin AddBuilder!(typeof(this));\n\n"); 310 buffer.put("}\n"); 311 } 312 313 string generateRequestParamType( 314 Appender!string buffer, OperationEntry operationEntry, string prefix = " ") { 315 OasParameter[] parameters = operationEntry.operation.parameters; 316 if (parameters is null || parameters.length == 0) 317 return null; 318 string className = toUpperCamelCase(operationEntry.operation.operationId) ~ "Params"; 319 buffer.put(prefix ~ "static class " ~ className ~ " {\n"); 320 foreach (OasParameter parameter; operationEntry.operation.parameters) { 321 writeCommentBlock(buffer, parameter.description, prefix ~ " ", 100); 322 generateSchemaInnerClasses(buffer, parameter.schema, prefix ~ " "); 323 generatePropertyCode(buffer, parameter.name, parameter.schema, prefix ~ " ", parameter.required); 324 } 325 buffer.put(prefix ~ " mixin AddBuilder!(typeof(this));\n\n"); 326 buffer.put(prefix ~ "}\n\n"); 327 return className; 328 } 329 330 class ResponseHandlerType { 331 string codeType; 332 } 333 334 /** 335 * Based on the request responses and their types, generate a "response handler" class that allows 336 * the caller to define handlers that are specific to the response type. 337 */ 338 ResponseHandlerType generateResponseHandlerType( 339 Appender!string buffer, OperationEntry operationEntry, string prefix = " ") { 340 if (operationEntry.operation.responses is null) 341 return null; 342 OasResponse[string] responses = operationEntry.operation.responses; 343 string typeName = (operationEntry.operation.operationId ~ "ResponseHandler").toUpperCamelCase(); 344 with (buffer) { 345 put(prefix ~ "static class " ~ typeName ~ " : ResponseHandler {\n\n"); 346 347 struct ResponseHandlerData { 348 string contentType; 349 string statusCode; 350 string responseSourceType; 351 string handlerMethodName; 352 } 353 ResponseHandlerData[] responseHandlerData; 354 355 // Create a handler method that can be defined for each HTTP status code and its corresponding 356 // response body type. 357 foreach (string statusCode, OasResponse oasResponse; responses) { 358 logDebug("Generating response for operationId=%s statusCode=%s", 359 operationEntry.operation.operationId, statusCode); 360 // Read the content type and pick out the first media type entry. 361 string contentType; 362 OasMediaType mediaType; 363 foreach (pair; oasResponse.content.byKeyValue()) { 364 logDebug("Checking contentType: %s", pair.key); 365 if (pair.key.indexOf("/") == -1 || pair.value.schema is null) { 366 logDebug("Skipping content type %s due to invalid type or missing schema.", pair.key); 367 continue; 368 } 369 contentType = pair.key; 370 mediaType = pair.value; 371 break; 372 } 373 import vibe.data.json : serializeToJsonString; 374 logDebug("Found mediaType: %s", serializeToJsonString(mediaType)); 375 // Determine if an inner class needs to be defined for the type, or if it references an 376 // existing schema. 377 string defaultResponseSourceType = 378 operationEntry.operation.operationId ~ "Response" ~ toUpperCamelCase(statusCode); 379 string responseSourceType = (mediaType) ? getSchemaCodeType(mediaType.schema, defaultResponseSourceType) : null; 380 381 // Generate an inner class if needed, otherwise, do nothing. 382 if (mediaType) { 383 generateSchemaInnerClasses(buffer, mediaType.schema, prefix ~ " ", defaultResponseSourceType); 384 } 385 386 writeCommentBlock(buffer, oasResponse.description, prefix ~ " "); 387 string handlerMethodName = "handleResponse" ~ toUpperCamelCase(statusCode); 388 put(prefix ~ " void delegate(" ~ (responseSourceType ? (responseSourceType ~ " response") : "") ~ ") " 389 ~ handlerMethodName ~ ";\n\n"); 390 391 // Save data needed to map response codes to methods to call. 392 responseHandlerData ~= 393 ResponseHandlerData(contentType, statusCode, responseSourceType, handlerMethodName); 394 } 395 396 // Generate a handler method that routes to the individual handler methods above. 397 put(prefix ~ " /**\n"); 398 put(prefix ~ " * An HTTPResponse handler that routes to a particular handler method.\n"); 399 put(prefix ~ " */\n"); 400 put(prefix ~ " void handleResponse(HTTPClientResponse res) {\n"); 401 ResponseHandlerData* defaultHandlerDatum = null; 402 foreach (ref ResponseHandlerData datum; responseHandlerData) { 403 if (datum.statusCode == "default") { 404 defaultHandlerDatum = &datum; 405 } else { 406 int statusCodeMin = datum.statusCode.tr("x", "0").to!int; 407 int statusCodeMax = datum.statusCode.tr("x", "9").to!int; 408 put(prefix ~ " if (res.statusCode >= " ~ statusCodeMin.to!string ~ " && res.statusCode <= " 409 ~ statusCodeMax.to!string ~ " && " ~ datum.handlerMethodName ~ " !is null) {\n"); 410 // TODO: Support additional response body types. 411 if (datum.contentType == "application/json") { 412 put(prefix ~ " " ~ datum.handlerMethodName ~ "(deserializeJson!(" 413 ~ datum.responseSourceType ~ ")(res.readJson()));\n"); 414 put(prefix ~ " return;\n"); 415 } else { 416 put(prefix ~ " writeln(\"Unsupported contentType " ~ datum.contentType ~ ".\");\n"); 417 } 418 put(prefix ~ " }\n"); 419 } 420 } 421 if (defaultHandlerDatum !is null) { 422 put(prefix 423 ~ " if (" ~ defaultHandlerDatum.handlerMethodName ~ " !is null) {\n" 424 ~ " " ~ defaultHandlerDatum.handlerMethodName ~ "(deserializeJson!(" 425 ~ defaultHandlerDatum.responseSourceType ~ ")(res.readJson()));\n" 426 ~ " return;\n" 427 ~ " }\n"); 428 } 429 put(prefix 430 ~ " throw new Exception(\"Unhandled response status code: \"\n" 431 ~ " ~ res.statusCode.to!string\n" 432 ~ " ~ \", Body: \" ~ res.bodyReader().readAllUTF8());\n"); 433 put(prefix ~ " }\n\n"); 434 put(prefix ~ " mixin AddBuilder!(typeof(this));\n\n"); 435 put(prefix ~ "}\n\n"); 436 } 437 438 ResponseHandlerType responseHandlerType = new ResponseHandlerType; 439 responseHandlerType.codeType = typeName; 440 return responseHandlerType; 441 }