1 /**
2  * Methods and classes used to generate classes and other data structures representing common
3  * schemas in an OpenAPI Specification.
4  *
5  * See_Also: https://spec.openapis.org/oas/latest.html#schema-object
6  */
7 module openapi_client.schemas;
8 
9 import vibe.data.json : Json, deserializeJson;
10 import vibe.core.log : logDebug;
11 
12 import std.container.rbtree : RedBlackTree;
13 import std.file : mkdir, mkdirRecurse, write;
14 import std.array : array, appender, split, Appender;
15 import std.algorithm : skipOver;
16 import std.path : buildNormalizedPath, dirName;
17 import std.string : tr;
18 import std.range : tail, takeOne;
19 import std.stdio : writeln;
20 
21 import openapi : OasDocument, OasSchema;
22 import openapi_client.util : toUpperCamelCase, wordWrapText;
23 
24 /**
25  * Descriptive information about a OpenAPI schema and the Dlang code that represents it.
26  *
27  * Information in this class is used during code generation via [jsonSchemaByRef].
28  */
29 class JsonSchema {
30   /**
31    * The name of the schema as per the OpenAPI Specification.
32    */
33   string schemaName;
34 
35   /**
36    * The name of the Dlang module that contains classes for the schema.
37    */
38   string moduleName;
39 
40   /**
41    * The name of the Dlang class representing this schema.
42    */
43   string className;
44 
45   /**
46    * The OpenAPI Specification data representing the schema, in case it needs re-processing.
47    */
48   OasSchema schema;
49 }
50 
51 JsonSchema[string] jsonSchemaByRef;
52 
53 /**
54  * In our code generation, the module is a file name which contains a class whose
55  * name is in CamelCase.
56  */
57 string getClassNameFromSchemaName(string schemaName) {
58   static immutable RedBlackTree!string RESERVED_CLASSES = new RedBlackTree!string([
59           "Error"
60                                                                                    ]);
61   string className = toUpperCamelCase(tr(schemaName, ".", "_"));
62   if (className in RESERVED_CLASSES)
63     return className ~ "_";
64   else
65     return className;
66 }
67 
68 /**
69  * Produce the full module name for a given schemaName and package.
70  *
71  * Params:
72  *   packageRoot = The D base-package for the modules, e.g. "stripe.openapi".
73  *   schemaName = The OpenAPI Spec schema name, e.g. "transfer_data".
74  */
75 string getModuleNameFromSchemaName(string packageRoot, string schemaName) {
76   if (packageRoot is null)
77     return schemaName;
78   else
79     return packageRoot ~ ".model." ~ schemaName;
80 }
81 
82 /**
83  * Returns the OpenAPI Specification schema name from a schema
84  * reference. E.g. "#/components/schemas/Thing" => "Thing".
85  */
86 string getSchemaNameFromRef(string ref_) {
87   string schemaName = ref_;
88   if (!skipOver(schemaName, "#/components/schemas/"))
89     throw new Exception("External references not supported! " ~ ref_);
90   return schemaName;
91 }
92 
93 /**
94  * Generates and writes to disk D-language files that correspond to the OpenAPI Document's
95  * components/schemas data. Depending on the software architecture ideas being used, such
96  * files can be known as "model" or "dto" files.
97  */
98 void writeSchemaFiles(OasDocument oasDocument, string targetDir, string packageRoot) {
99   foreach (string schemaName, OasSchema schema; oasDocument.components.schemas) {
100     JsonSchema jsonSchema = new JsonSchema();
101     jsonSchema.schemaName = schemaName;
102     jsonSchema.moduleName = getModuleNameFromSchemaName(packageRoot, schemaName);
103     jsonSchema.className = getClassNameFromSchemaName(schemaName);
104     jsonSchema.schema = schema;
105     string ref_ = "#/components/schemas/" ~ schemaName;
106     jsonSchemaByRef[ref_] = jsonSchema;
107     writeln("Added reference: ", ref_);
108 
109     generateModuleCode(targetDir, jsonSchema, schema, packageRoot);
110   }
111 }
112 
113 /**
114  * A collection of variable names that cannot be used to generate Dlang code.
115  */
116 static immutable RedBlackTree!string RESERVED_WORDS = new RedBlackTree!string([
117       "abstract",
118       "alias",
119       "align",
120       "asm",
121       "assert",
122       "auto",
123       "bool",
124       "break",
125       "byte",
126       "case",
127       "catch",
128       "cast",
129       "char",
130       "class",
131       "const",
132       "continue",
133       "dchar",
134       "debug",
135       "delegate",
136       "double",
137       "dstring",
138       "else",
139       "enum",
140       "export",
141       "extern",
142       "finally",
143       "float",
144       "for",
145       "foreach",
146       "foreach_reverse",
147       "function",
148       "if",
149       "in",
150       "invariant",
151       "immutable",
152       "import",
153       "int",
154       "lazy",
155       "long",
156       "mixin",
157       "module",
158       "new",
159       "nothrow",
160       "package",
161       "private",
162       "public",
163       "pure",
164       "real",
165       "scope",
166       "string",
167       "struct",
168       "switch",
169       "ulong",
170       "union",
171       "version",
172       "wchar",
173       "while",
174       "with",
175       "wstring",
176     ]);
177 
178 /**
179  * Writes a Dlang source file for a module that contains a class representing an OpenAPI
180  * Specification schema.
181  */
182 void generateModuleCode(string targetDir, JsonSchema jsonSchema, OasSchema oasSchema, string packageRoot) {
183   auto buffer = appender!string();
184   with (buffer) {
185     put("// File automatically generated from OpenAPI spec.\n");
186     put("module " ~ jsonSchema.moduleName ~ ";\n\n");
187     // We generate the class in a separate buffer, because it's production may add dependencies.
188     auto classBuffer = appender!string();
189     generateClassCode(  // TODO: Pass the entire schema as an argument, not just parts of it.
190         classBuffer, oasSchema.description, jsonSchema.className, oasSchema.properties);
191 
192     put("import vibe.data.serialization : optional;\n");
193     put("import vibe.data.json : Json;\n");
194     put("import builder : AddBuilder;\n");
195     put("\n");
196     put("import std.typecons : Nullable;\n\n");
197     // While generating the class code, we accumulated external references to import.
198     foreach (string schemaRef; getSchemaReferences(oasSchema)) {
199       string schemaName = getSchemaNameFromRef(schemaRef);
200       // Do not add an import for self-references.
201       if (schemaName == jsonSchema.schemaName)
202         continue;
203       put("import ");
204       put(getModuleNameFromSchemaName(packageRoot, schemaName));
205       put(" : ");
206       put(getClassNameFromSchemaName(schemaName));
207       put(";\n");
208     }
209     put("\n");
210 
211     // Finally add our class code to the module file.
212     put(classBuffer[]);
213   }
214   string fileName = buildNormalizedPath(targetDir, tr(jsonSchema.moduleName, ".", "/") ~ ".d");
215   writeln("Writing file: ", fileName);
216   mkdirRecurse(dirName(fileName));
217   write(fileName, buffer[]);
218 }
219 
220 /**
221  * Writes to a buffer a Dlang class representing an OpenAPI Specification schema.
222  */
223 void generateClassCode(
224     Appender!string buffer, string description, string className, OasSchema[string] properties) {
225   with (buffer) {
226     // Display the class description and declare it.
227     put("/**\n");
228     foreach (string line; wordWrapText(description, 95)) {
229       put(" * ");
230       put(line);
231       put("\n");
232     }
233     put(" */\n");
234     put("class " ~ className ~ " {\n");
235     // Define individual properties of the class.
236     foreach (string propertyName, OasSchema propertySchema; properties) {
237       try {
238         generateSchemaInnerClasses(buffer, propertySchema, "  ");
239         generatePropertyCode(buffer, propertyName, propertySchema, "  ");
240       } catch (Exception e) {
241         writeln("Error writing className=", className);
242         throw e;
243       }
244     }
245     put("  mixin AddBuilder!(typeof(this));\n\n");
246     put("}\n");
247   }
248 }
249 
250 /**
251  * Produce code for a class declaring a named property based on an [OasSchema] for the property.
252  */
253 void generatePropertyCode(
254     Appender!string buffer, string propertyName, OasSchema propertySchema, string prefix = "  ") {
255   string propertyCodeType = getSchemaCodeType(propertySchema);
256   if (propertyCodeType is null)
257     return;
258   if (propertySchema.description !is null) {
259     buffer.put(prefix);
260     buffer.put("/**\n");
261     foreach (string line; wordWrapText(propertySchema.description, 93)) {
262       buffer.put(prefix);
263       buffer.put(" * ");
264       buffer.put(line);
265       buffer.put("\n");
266     }
267     buffer.put(prefix);
268     buffer.put(" */\n");
269   }
270   try {
271     buffer.put(prefix ~ "@optional\n");
272     buffer.put(prefix ~ propertyCodeType ~ " "
273         ~ getVariableName(propertyName) ~ ";\n\n");
274   } catch (Exception e) {
275     writeln("Error writing propertyName=", propertyName,
276         ", propertyDescription=", propertySchema.description);
277     throw e;
278   }
279 }
280 
281 /**
282  * Not every propertyName to be found in an OpenAPI Specification Document can be used to a
283  * variable name in code. E.g. the name "scope" is a reserved word in D, and must be replaced with
284  * "scope_".
285  */
286 static string getVariableName(string propertyName) {
287   if (propertyName in RESERVED_WORDS)
288     return propertyName ~ "_";
289   else
290     return propertyName;
291 }
292 
293 /**
294  * Some OasSchema types refer to unnamed objects that have a fixed set of
295  * parameters. The best representation of this in D is a named class.
296  *
297  * Most types, like a simple "integer" or "string" will not generate any inner classes, but a few
298  * cases will, such as "object" with a specific set of valid "properties".
299  */
300 void generateSchemaInnerClasses(
301     Appender!string buffer, OasSchema schema, string prefix="  ", string defaultName = null,
302     RedBlackTree!string context = null) {
303   // To prevent the same class from being generated twice (which can happen when two properties are
304   // themselves objects and have an identical set of properties and names), keep track of class
305   // names that have been generated.
306   if (context is null)
307     context = new RedBlackTree!string();
308   // Otherwise, we create static inner classes that match any objects, arrays, or other things that
309   // are defined.
310   if (schema.type == "object") {
311     if (schema.properties is null) {
312       // If additionalProperties is an object, it's a schema for the data type, but an arbitrary set
313       // of attributes may exist.
314       if (schema.additionalProperties.type == Json.Type.Undefined
315           || schema.additionalProperties.type == Json.Type.Bool) {
316         // Do not generate a class, this will be either Json or nothing at all.
317       }
318       else if (schema.additionalProperties.type == Json.Type.Object) {
319         OasSchema propertySchema = deserializeJson!OasSchema(schema.additionalProperties);
320         generateSchemaInnerClasses(buffer, propertySchema, prefix, defaultName, context);
321       }
322     }
323     else {
324       // We will have to make a class/struct out of this type from its name.
325       string className = (schema.title !is null) ? schema.title.toUpperCamelCase() : defaultName;
326       if (className is null)
327         throw new Exception("Creating an Inner Class for property requires a title or default name!");
328       if (className in context) {
329         writeln("Avoiding generating duplicate inner class '", className, "'.");
330         return;
331       }
332       if (schema.additionalProperties.type == Json.Type.Undefined ||
333           (schema.additionalProperties.type == Json.Type.Bool
334               && schema.additionalProperties.get!bool == true)) {
335         writeln("Warning: ", className, " may have additional properties!");
336       }
337       buffer.put(prefix);
338       buffer.put("static class " ~ className ~ " {\n");
339       // Before we start a new context, let the previous one know about the class being defined.
340       context.insert(className);
341       // Start a new context, because the inner class creates a new naming scope.
342       context = new RedBlackTree!string();
343       foreach (string propertyName, OasSchema propertySchema; schema.properties) {
344         generateSchemaInnerClasses(buffer, propertySchema, prefix ~ "  ", null, context);
345         generatePropertyCode(buffer, propertyName, propertySchema, prefix ~ "  ");
346       }
347       buffer.put(prefix ~ "  mixin AddBuilder!(typeof(this));\n\n");
348       buffer.put(prefix ~ "}\n\n");
349     }
350   }
351   // The type might be an array and it's schema could be hidden beneath.
352   else if (schema.type == "array" && schema.items !is null) {
353     generateSchemaInnerClasses(buffer, schema.items, prefix, defaultName, context);
354   }
355   // Sometimes data has no explicit properties, but we can infer them from validation data.
356   else if (schema.anyOf !is null && schema.anyOf.length == 1) {
357     generateSchemaInnerClasses(buffer, schema.anyOf[0], prefix, null, context);
358   }
359 }
360 
361 /**
362  * Converts a given [OasSchema] type into the equivalent type in source code.
363  *
364  * Params:
365  *   defaultName = If a structured type can be created as an inner class, the default name to use
366  *     for that class.
367  */
368 string getSchemaCodeType(OasSchema schema, string defaultName = null) {
369   // This could be a reference to an existing type.
370   if (schema.ref_ !is null) {
371     string schemaName = getSchemaNameFromRef(schema.ref_);
372     // Resolving this class name depends on having an import statement.
373     return getClassNameFromSchemaName(schemaName);
374   }
375   // First check if we have a primitive type.
376   // TODO: Use the "schema.required" to determine which are nullable.
377   else if (schema.type !is null) {
378     if (schema.type == "integer") {
379       if (schema.format == "int32")
380         return "Nullable!(int)";
381       else if (schema.format == "int64")
382         return "Nullable!(long)";
383       else if (schema.format == "unix-time")
384         return "Nullable!(long)";
385       return "Nullable!(int)";
386     } else if (schema.type == "number") {
387       if (schema.format == "float")
388         return "Nullable!(float)";
389       else if (schema.format == "double")
390         return "Nullable!(double)";
391       return "Nullable!(float)";
392     } else if (schema.type == "boolean") {
393       return "Nullable!(bool)";
394     } else if (schema.type == "string") {
395       return "string";
396     } else if (schema.type == "array") {
397       string arrayCodeType = getSchemaCodeType(schema.items);
398       return arrayCodeType !is null ? arrayCodeType ~ "[]" : null;
399     } else if (schema.type == "object") {
400       // If we are missing both properties and additionalProperties, we assume a generic string[string] object.
401       if (schema.properties is null) {
402         // If additionalProperties is an object, it's a schema for the data type, but any number of
403         // fields may exist.
404         if (schema.additionalProperties.type == Json.Type.Object) {
405           OasSchema propertySchema = deserializeJson!OasSchema(schema.additionalProperties);
406           string propertyCodeType = getSchemaCodeType(propertySchema);
407           return propertyCodeType !is null ? propertyCodeType ~ "[string]" : null;
408         }
409         // If additional properties exist, but we have no type information, it can be anything.
410         else if (schema.additionalProperties.type == Json.Type.Undefined
411             || (schema.additionalProperties.type == Json.Type.Bool
412                 && schema.additionalProperties.get!bool == true)) {
413           return "Json";
414         }
415         // If there are no properties, and no additional properties, then it's not a type at all.
416         else {
417           return null;
418         }
419       }
420       // If properties are present we can safely assume a class will be created.
421       else {
422         // We will have to make a class/struct out of this type from its name.
423         if (schema.title !is null)
424           return schema.title.toUpperCamelCase();
425         else if (defaultName !is null)
426           return defaultName;
427         throw new Exception("Creating a named object type requires a title or defaultName!");
428       }
429     }
430   }
431   // Perhaps we can infer the type from the "anyOf" validation.
432   else if (schema.anyOf !is null && schema.anyOf.length == 1) {
433     return getSchemaCodeType(schema.anyOf[0]);
434   }
435   // If all else fails, put the programmer in the driver's seat.
436   return "Json";
437 }
438 
439 /**
440  * When using a schema, it may reference other external schemas which have to be imported into any
441  * module that uses them.
442  */
443 string[] getSchemaReferences(OasSchema schema) {
444   RedBlackTree!string refs = new RedBlackTree!string();
445   getSchemaReferences(schema, refs);
446   return refs[].array;
447 }
448 
449 /// ditto
450 private void getSchemaReferences(OasSchema schema, ref RedBlackTree!string refs) {
451   if (schema.ref_ !is null) {
452     refs.insert(schema.ref_);
453   } else if (schema.type == "array") {
454     getSchemaReferences(schema.items, refs);
455   } else if (schema.type == "object") {
456     if (schema.properties !is null) {
457       foreach (string propertyName, OasSchema propertySchema; schema.properties) {
458         getSchemaReferences(propertySchema, refs);
459       }
460     }
461     if (schema.additionalProperties.type == Json.Type.Object) {
462       getSchemaReferences(deserializeJson!OasSchema(schema.additionalProperties), refs);
463     }
464   } else if (schema.anyOf !is null) {
465     foreach (OasSchema anyOfSchema; schema.anyOf) {
466       getSchemaReferences(anyOfSchema, refs);
467     }
468   } else if (schema.allOf !is null) {
469     foreach (OasSchema allOfSchema; schema.allOf) {
470       getSchemaReferences(allOfSchema, refs);
471     }
472   }
473 }