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 }