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 }