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