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 }