Skip to content

Commit 2a7944e

Browse files
committed
added support for 'oneOf' types represented as unions
also updated libs and an 'errors' field rename to address name clashes with likely/popular field names
1 parent 2c38d0d commit 2a7944e

27 files changed

+249
-93
lines changed

modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/ScalaCaskServerCodegen.java

+81-21
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818

1919
import io.swagger.v3.oas.models.OpenAPI;
2020
import io.swagger.v3.oas.models.media.Schema;
21+
import io.swagger.v3.oas.models.parameters.RequestBody;
2122
import org.apache.commons.io.FileUtils;
2223
import org.openapitools.codegen.*;
2324
import org.openapitools.codegen.model.ModelMap;
@@ -394,6 +395,12 @@ public void processOpenAPI(OpenAPI openAPI) {
394395
}
395396

396397

398+
/**
399+
* This class is used in pathExtractorParams.mustache.
400+
*
401+
* It exposes some methods which make it more readable
402+
* for that mustache snippet, and also isolates the logic needed for the path extractors
403+
*/
397404
public static class ParamPart {
398405
final CodegenParameter param;
399406
final String name;
@@ -416,7 +423,9 @@ public ParamPart(String name, CodegenParameter param) {
416423
}
417424

418425
/**
419-
* Cask will compile but 'initialize' can throw a route overlap exception:
426+
* This data structure is here to manually identify and fix routes which will overlap (e.g. GET /foo/bar and GET /foo/bazz)
427+
*
428+
* If we added these as individual routes, then Cask itself will compile, but calling 'initialize' throws a route overlap exception:
420429
* <p>
421430
* {{{
422431
* Routes overlap with wildcards: get /user/logout, get /user/:username, get /user/login
@@ -672,9 +681,12 @@ private void postProcessModel(CodegenModel model) {
672681

673682
model.getVars().forEach(this::postProcessProperty);
674683
model.getAllVars().forEach(this::postProcessProperty);
684+
685+
686+
model.vendorExtensions.put("x-has-one-of", model.oneOf != null && !model.oneOf.isEmpty());
675687
}
676688

677-
private static void postProcessOperation(CodegenOperation op) {
689+
private static void postProcessOperation(final CodegenOperation op) {
678690
// force http method to lower case
679691
op.httpMethod = op.httpMethod.toLowerCase(Locale.ROOT);
680692

@@ -710,9 +722,33 @@ private static void postProcessOperation(CodegenOperation op) {
710722
.collect(Collectors.toCollection(LinkedHashSet::new));
711723

712724
var responseType = responses.isEmpty() ? "Unit" : String.join(" | ", responses);
725+
op.vendorExtensions.put("x-import-response-implicits", importResponseImplicits(op));
713726
op.vendorExtensions.put("x-response-type", responseType);
714727
}
715728

729+
/**
730+
* We need to bring the response type into scope in order to use the upickle implicits
731+
* only if the response type has a 'oneOf' type, which means it's a union type with a
732+
* companion object containing the ReadWriter
733+
*
734+
* @param op
735+
* @return true if we need to provide an import
736+
*/
737+
private static boolean importResponseImplicits(final CodegenOperation op) {
738+
final Set<String> importBlacklist = Set.of("File");
739+
740+
boolean doImport = false;
741+
for (var response : op.responses) {
742+
// we should ignore generic types like Seq[...] or Map[..] types
743+
var isPolymorphic = response.dataType != null && response.dataType.contains("[");
744+
if (response.isModel && !importBlacklist.contains(response.dataType) && !isPolymorphic) {
745+
doImport = true;
746+
break;
747+
}
748+
}
749+
return doImport;
750+
}
751+
716752
/**
717753
* primitive or enum types don't have Data representations
718754
* @param p the property
@@ -747,6 +783,10 @@ private static boolean isByteArray(final CodegenProperty p) {
747783
return "byte".equalsIgnoreCase(p.dataFormat); // &&
748784
}
749785

786+
private static boolean wrapInOptional(CodegenProperty p) {
787+
return !p.required && !p.isArray && !p.isMap;
788+
}
789+
750790
/**
751791
* this parameter is used to create the function:
752792
* {{{
@@ -761,19 +801,18 @@ private static boolean isByteArray(final CodegenProperty p) {
761801
* and then back again
762802
*/
763803
private static String asDataCode(final CodegenProperty p, final Set<String> typesWhichDoNotNeedMapping) {
764-
final var wrapInOptional = !p.required && !p.isArray && !p.isMap;
765804
String code = "";
766805

767806
String dv = defaultValueNonOption(p, p.defaultValue);
768807

769808
if (doesNotNeedMapping(p, typesWhichDoNotNeedMapping)) {
770-
if (wrapInOptional) {
809+
if (wrapInOptional(p)) {
771810
code = String.format(Locale.ROOT, "%s.getOrElse(%s) /* 1 */", p.name, dv);
772811
} else {
773812
code = String.format(Locale.ROOT, "%s /* 2 */", p.name);
774813
}
775814
} else {
776-
if (wrapInOptional) {
815+
if (wrapInOptional(p)) {
777816
if (isByteArray(p)) {
778817
code = String.format(Locale.ROOT, "%s.getOrElse(%s) /* 3 */", p.name, dv);
779818
} else {
@@ -782,11 +821,15 @@ private static String asDataCode(final CodegenProperty p, final Set<String> type
782821
} else if (p.isArray) {
783822
if (isByteArray(p)) {
784823
code = String.format(Locale.ROOT, "%s /* 5 */", p.name);
824+
} else if (!isObjectArray(p)) {
825+
code = String.format(Locale.ROOT, "%s /* 5.1 */", p.name);
785826
} else {
786827
code = String.format(Locale.ROOT, "%s.map(_.asData) /* 6 */", p.name);
787828
}
829+
} else if (p.isMap) {
830+
code = String.format(Locale.ROOT, "%s /* 7 */", p.name);
788831
} else {
789-
code = String.format(Locale.ROOT, "%s.asData /* 7 */", p.name);
832+
code = String.format(Locale.ROOT, "%s.asData /* 8 */", p.name);
790833
}
791834
}
792835
return code;
@@ -807,24 +850,25 @@ private static String asDataCode(final CodegenProperty p, final Set<String> type
807850
* @return
808851
*/
809852
private static String asModelCode(final CodegenProperty p, final Set<String> typesWhichDoNotNeedMapping) {
810-
final var wrapInOptional = !p.required && !p.isArray && !p.isMap;
811853
String code = "";
812854

813855
if (doesNotNeedMapping(p, typesWhichDoNotNeedMapping)) {
814-
if (wrapInOptional) {
856+
if (wrapInOptional(p)) {
815857
code = String.format(Locale.ROOT, "Option(%s) /* 1 */", p.name);
816858
} else {
817859
code = String.format(Locale.ROOT, "%s /* 2 */", p.name);
818860
}
819861
} else {
820-
if (wrapInOptional) {
862+
if (wrapInOptional(p)) {
821863
if (isByteArray(p)) {
822864
code = String.format(Locale.ROOT, "Option(%s) /* 3 */", p.name);
823865
} else {
824866
code = String.format(Locale.ROOT, "Option(%s).map(_.asModel) /* 4 */", p.name);
825867
}
826868
} else if (p.isArray) {
827869
code = String.format(Locale.ROOT, "%s.map(_.asModel) /* 5 */", p.name);
870+
} else if (p.isMap) {
871+
code = String.format(Locale.ROOT, "%s /* 5.1 */", p.name);
828872
} else {
829873
code = String.format(Locale.ROOT, "%s.asModel /* 6 */", p.name);
830874
}
@@ -863,8 +907,17 @@ private String ensureNonKeyword(String text) {
863907
return text;
864908
}
865909

910+
private static boolean hasItemModel(final CodegenProperty p) {
911+
return p.items != null && p.items.isModel;
912+
}
913+
914+
private static boolean isObjectArray(final CodegenProperty p) {
915+
return p.isArray && hasItemModel(p);
916+
}
917+
866918
private void postProcessProperty(final CodegenProperty p) {
867-
p.vendorExtensions.put("x-datatype-model", asScalaDataType(p, p.required, false));
919+
920+
p.vendorExtensions.put("x-datatype-model", asScalaDataType(p, p.required, false, wrapInOptional(p)));
868921
p.vendorExtensions.put("x-defaultValue-model", defaultValue(p, p.required, p.defaultValue));
869922
final String dataTypeData = asScalaDataType(p, p.required, true);
870923
p.vendorExtensions.put("x-datatype-data", dataTypeData);
@@ -878,7 +931,7 @@ private void postProcessProperty(final CodegenProperty p) {
878931
p._enum = p._enum.stream().map(this::ensureNonKeyword).collect(Collectors.toList());
879932
}
880933

881-
/**
934+
/*
882935
* This is a fix for the enum property "type" declared like this:
883936
* {{{
884937
* type:
@@ -908,6 +961,9 @@ private void postProcessProperty(final CodegenProperty p) {
908961
)).collect(Collectors.toSet());
909962
typesWhichShouldNotBeMapped.add("byte");
910963

964+
// when deserialising map objects, the logic is tricky.
965+
p.vendorExtensions.put("x-deserialize-asModelMap", p.isMap && hasItemModel(p));
966+
911967
// the 'asModel' logic for modelData.mustache
912968
//
913969
// if it's optional (not required), then wrap the value in Option()
@@ -916,16 +972,6 @@ private void postProcessProperty(final CodegenProperty p) {
916972
p.vendorExtensions.put("x-asData", asDataCode(p, typesWhichShouldNotBeMapped));
917973
p.vendorExtensions.put("x-asModel", asModelCode(p, typesWhichShouldNotBeMapped));
918974

919-
// if it's an array or optional, we need to map it as a model -- unless it's a map,
920-
// in which case we have to map the values
921-
boolean hasItemModel = p.items != null && p.items.isModel;
922-
boolean isObjectArray = p.isArray && hasItemModel;
923-
boolean isOptionalObj = !p.required && p.isModel;
924-
p.vendorExtensions.put("x-map-asModel", (isOptionalObj || isObjectArray) && !p.isMap);
925-
926-
// when deserialising map objects, the logic is tricky.
927-
p.vendorExtensions.put("x-deserialize-asModelMap", p.isMap && hasItemModel);
928-
929975
// for some reason, an openapi spec with pattern field like this:
930976
// pattern: '^[A-Za-z]+$'
931977
// will result in the pattern property text of
@@ -934,6 +980,20 @@ private void postProcessProperty(final CodegenProperty p) {
934980
p.pattern = p.pattern.substring(1, p.pattern.length() - 1);
935981
}
936982

983+
// in our model class definition laid out in modelClass.mustache, we use 'Option' for non-required
984+
// properties only when they don't have a sensible 'empty' value (e.g. maps and lists).
985+
//
986+
// that is to say, we're trying to avoid having:
987+
//
988+
// someOptionalField : Option[Seq[Foo]]
989+
//
990+
// when we could just have e.g.
991+
//
992+
// someOptionalField : Seq[Foo]
993+
//
994+
// with an empty value
995+
p.vendorExtensions.put("x-model-needs-option", wrapInOptional(p));
996+
937997
}
938998

939999

modules/openapi-generator/src/main/resources/scala-cask/apiRoutes.mustache

+4
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,10 @@ class {{classname}}Routes(service : {{classname}}Service[Try]) extends cask.Rout
4545

4646
val result = {{>parseHttpParams}}
4747

48+
{{#vendorExtensions.x-import-response-implicits}}
49+
import {{vendorExtensions.x-response-type}}.{given, *} // this brings in upickle in the case of union (oneOf) types
50+
{{/vendorExtensions.x-import-response-implicits}}
51+
4852
(result : @unchecked) match {
4953
case Left(error) => cask.Response(error, 500)
5054
{{#responses}}

modules/openapi-generator/src/main/resources/scala-cask/model.mustache

+18-6
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,24 @@ import upickle.default.*
1313
{{#models}}
1414
{{#model}}
1515

16-
{{#isEnum}}
17-
{{>modelEnum}}
18-
{{/isEnum}}
19-
{{^isEnum}}
20-
{{>modelClass}}
21-
{{/isEnum}}
16+
{{#vendorExtensions.x-has-one-of}}
17+
18+
type {{classname}} = {{#oneOf}}{{{.}}}{{^-last}} | {{/-last}}{{/oneOf}}
19+
object {{{classname}}} {
20+
21+
given RW[{{{classname}}}] = RW.merge({{#oneOf}}summon[RW[{{{.}}}]]{{^-last}}, {{/-last}}{{/oneOf}})
22+
}
23+
24+
{{/vendorExtensions.x-has-one-of}}
25+
{{^vendorExtensions.x-has-one-of}}
26+
{{#isEnum}}
27+
{{>modelEnum}}
28+
{{/isEnum}}
29+
{{^isEnum}}
30+
{{>modelClass}}
31+
{{/isEnum}}
32+
{{/vendorExtensions.x-has-one-of}}
33+
2234

2335
{{/model}}
2436
{{/models}}

modules/openapi-generator/src/main/resources/scala-cask/modelClass.mustache

+1-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ case class {{classname}}(
44
{{#description}}
55
/* {{{description}}} */
66
{{/description}}
7-
{{name}}: {{#isEnum}}{{^required}}Option[{{/required}}{{classname}}.{{datatypeWithEnum}}{{^required}}]{{/required}}{{/isEnum}}{{^isEnum}}{{{vendorExtensions.x-datatype-model}}}{{/isEnum}}{{^required}} = {{{vendorExtensions.x-defaultValue-model}}} {{/required}}{{^-last}},{{/-last}}
7+
{{name}}: {{#isEnum}}{{#vendorExtensions.x-model-needs-option}}Option[{{/vendorExtensions.x-model-needs-option}}{{classname}}.{{datatypeWithEnum}}{{#vendorExtensions.x-model-needs-option}}]{{/vendorExtensions.x-model-needs-option}}{{/isEnum}}{{^isEnum}}{{{vendorExtensions.x-datatype-model}}}{{/isEnum}}{{^required}} = {{{vendorExtensions.x-defaultValue-model}}} {{/required}}{{^-last}},{{/-last}}
88
{{/vars}}
99

1010
{{#isAdditionalPropertiesTrue}}, additionalProperties : ujson.Value = ujson.Null{{/isAdditionalPropertiesTrue}}

modules/openapi-generator/src/main/resources/scala-cask/modelData.mustache

+29
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,40 @@ import upickle.default.*
1313
{{#models}}
1414
{{#model}}
1515

16+
{{#vendorExtensions.x-has-one-of}}
17+
type {{{classname}}}Data = {{#oneOf}}{{{.}}}Data{{^-last}} | {{/-last}}{{/oneOf}}
18+
19+
object {{{classname}}}Data {
20+
21+
def validated(d8a : {{{classname}}}Data, failFast: Boolean) : Try[{{{classname}}}] = {
22+
d8a match {
23+
{{#oneOf}}
24+
case value : {{{.}}}Data => value.validated(failFast)
25+
{{/oneOf}}
26+
}
27+
}
28+
29+
def fromJsonString(jason : String) = fromJson {
30+
try {
31+
read[ujson.Value](jason)
32+
} catch {
33+
case NonFatal(e) => sys.error(s"Error parsing json '$jason': $e")
34+
}
35+
}
36+
37+
def fromJson(jason : ujson.Value) : {{{classname}}}Data = {
38+
val attempt = {{#oneOf}}{{^-first}}.orElse({{/-first}} Try({{{.}}}Data.fromJson(jason)) {{^-first}}) /* not first */{{/-first}} {{/oneOf}}
39+
attempt.get
40+
}
41+
}
42+
{{/vendorExtensions.x-has-one-of}}
43+
{{^vendorExtensions.x-has-one-of}}
1644
{{#isEnum}}
1745
{{>modelDataEnum}}
1846
{{/isEnum}}
1947
{{^isEnum}}
2048
{{>modelDataClass}}
2149
{{/isEnum}}
50+
{{/vendorExtensions.x-has-one-of}}
2251
{{/model}}
2352
{{/models}}

0 commit comments

Comments
 (0)