Skip to content

Commit

Permalink
scala-cask fix: Added support for 'additionalProperties:true' (#19767)
Browse files Browse the repository at this point in the history
* Added support for 'additionalProperties:true' to scala-cask generator

additionalProperties means the request can contain arbitrary
additional properties, and so this change adds an 'additionalProperties'
field to request objects which is a json type.

* fixed warning in example scala-cli project

* updated samples

* addressed codegen comments
  • Loading branch information
aaronp authored Oct 9, 2024
1 parent d60200d commit 31be9b9
Show file tree
Hide file tree
Showing 30 changed files with 503 additions and 669 deletions.
File renamed without changes.
Original file line number Diff line number Diff line change
Expand Up @@ -3158,7 +3158,7 @@ protected void setAddProps(Schema schema, IJsonSchemaValidationProperties proper
additionalPropertiesIsAnyType = true;
}
} else {
// if additioanl properties is set (e.g. free form object, any type, string, etc)
// if additional properties is set (e.g. free form object, any type, string, etc)
addPropProp = fromProperty(getAdditionalPropertiesName(), (Schema) schema.getAdditionalProperties(), false);
additionalPropertiesIsAnyType = true;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@
public class ScalaCaskServerCodegen extends AbstractScalaCodegen implements CodegenConfig {
public static final String PROJECT_NAME = "projectName";

// this is our opinionated json type - ujson.Value - which is a first-class
// citizen of cask
private static final String AdditionalPropertiesType = "Value";

private final Logger LOGGER = LoggerFactory.getLogger(ScalaCaskServerCodegen.class);

@Override
Expand Down Expand Up @@ -115,6 +119,8 @@ public ScalaCaskServerCodegen() {

typeMapping.put("integer", "Int");
typeMapping.put("long", "Long");
typeMapping.put("AnyType", AdditionalPropertiesType);

//TODO binary should be mapped to byte array
// mapped to String as a workaround
typeMapping.put("binary", "String");
Expand Down Expand Up @@ -241,6 +247,7 @@ public void processOpts() {
importMapping.put("OffsetDateTime", "java.time.OffsetDateTime");
importMapping.put("LocalTime", "java.time.LocalTime");
importMapping.put("Value", "ujson.Value");
importMapping.put(AdditionalPropertiesType, "ujson.Value");
}

static boolean consumesMimetype(CodegenOperation op, String mimetype) {
Expand Down Expand Up @@ -614,7 +621,7 @@ private void setDefaultValueForCodegenProperty(CodegenProperty p) {
if (p.getIsEnumOrRef()) {
p.defaultValue = "null";
} else {
p.defaultValue = defaultValueNonOption(p);
p.defaultValue = defaultValueNonOption(p, "null");
}
} else if (p.defaultValue.contains("Seq.empty")) {
p.defaultValue = "Nil";
Expand Down Expand Up @@ -767,6 +774,23 @@ private static String defaultValue(IJsonSchemaValidationProperties p, boolean re
return defaultValueNonOption(p, fallbackDefaultValue);
}

/**
* the subtypes of IJsonSchemaValidationProperties have an 'isNumeric', but that's not a method on IJsonSchemaValidationProperties.
*
* This helper method tries to isolate that noisy logic in a safe way so we can ask 'is this IJsonSchemaValidationProperties numeric'?
* @param p the property
* @return true if the property is numeric
*/
private static boolean isNumeric(IJsonSchemaValidationProperties p) {
if (p instanceof CodegenParameter) {
return ((CodegenParameter)p).isNumeric;
} else if (p instanceof CodegenProperty) {
return ((CodegenProperty)p).isNumeric;
} else {
return p.getIsNumber() || p.getIsFloat() || p.getIsDecimal() || p.getIsDouble() || p.getIsInteger() || p.getIsLong() || p.getIsUnboundedInteger();
}
}

private static String defaultValueNonOption(IJsonSchemaValidationProperties p, String fallbackDefaultValue) {
if (p.getIsArray()) {
if (p.getUniqueItems()) {
Expand All @@ -777,7 +801,7 @@ private static String defaultValueNonOption(IJsonSchemaValidationProperties p, S
if (p.getIsMap()) {
return "Map.empty";
}
if (p.getIsNumber()) {
if (isNumeric(p)) {
return "0";
}
if (p.getIsEnum()) {
Expand All @@ -792,37 +816,12 @@ private static String defaultValueNonOption(IJsonSchemaValidationProperties p, S
if (p.getIsString()) {
return "\"\"";
}
return fallbackDefaultValue;
}

private static String defaultValueNonOption(CodegenProperty p) {
if (p.getIsArray()) {
return "Nil";
}
if (p.getIsMap()) {
return "Map.empty";
}
if (p.isNumber || p.isNumeric) {
return "0";
}
if (p.isBoolean) {
return "false";
}
if (p.isUuid) {
return "java.util.UUID.randomUUID()";
}
if (p.isModel) {
return "null";
}
if (p.isDate || p.isDateTime) {
return "null";
}
if (p.isString) {
return "\"\"";
if (fallbackDefaultValue != null && !fallbackDefaultValue.trim().isEmpty()) {
return fallbackDefaultValue;
}
return p.defaultValue;
}

return "null";
}

@Override
public CodegenProperty fromProperty(String name, Schema schema) {
Expand All @@ -847,9 +846,9 @@ public String getTypeDeclaration(Schema schema) {

@Override
public String toModelImport(String name) {
final String result = super.toModelImport(name);
String result = super.toModelImport(name);
if (importMapping.containsKey(name)) {
return importMapping.get(name);
result = importMapping.get(name);
}
return result;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import java.time.LocalDate
import java.util.UUID
import scala.reflect.ClassTag
import scala.util.*
import upickle.default.*

// needed for BigDecimal params
given cask.endpoints.QueryParamReader.SimpleParam[BigDecimal](BigDecimal.apply)
Expand Down Expand Up @@ -142,6 +143,15 @@ extension (request: cask.Request) {
def headerManyValues(paramName: String, required: Boolean): Parsed[List[String]] = Parsed.manyValues(request.headers, paramName, required)

def bodyAsString = new String(request.readAllBytes(), "UTF-8")

def bodyAsJson : Try[ujson.Value] = {
val jason = bodyAsString
try {
Success(read[ujson.Value](jason))
} catch {
case scala.util.control.NonFatal(e) => sys.error(s"Error parsing json '$jason': $e")
}
}

def pathValue(index: Int, paramName: String, required : Boolean): Parsed[String] = {
request
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ class {{classname}}Routes(service : {{classname}}Service) extends cask.Routes {

val result = {{>parseHttpParams}}

result match {
(result : @unchecked) match {
case Left(error) => cask.Response(error, 500)
{{#responses}}
{{#dataType}}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
//> using scala "3.3.1"
//> using lib "com.lihaoyi::cask:0.9.2"
//> using lib "com.lihaoyi::scalatags:0.8.2"
//> using dep "com.lihaoyi::cask:0.9.2"
//> using dep "com.lihaoyi::scalatags:0.8.2"
{{>licenseInfo}}

// this file was generated from app.mustache
Expand All @@ -11,11 +11,21 @@ package {{packageName}}
import _root_.{{modelPackage}}.*
import _root_.{{apiPackage}}.*

/** an example of how you can add your own additional routes to your app */
object MoreRoutes extends cask.Routes {
@cask.get("/echo")
def more(request: cask.Request) = s"request was ${request.bodyAsString}"
initialize()
}

/**
* This is an example of how you might extends BaseApp for a runnable application.
*
* See the README.md for how to create your own app
*/
object ExampleApp extends BaseApp() {
// override to include our additional route
override def allRoutes = super.allRoutes ++ Option(MoreRoutes)
start()
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,26 +17,28 @@ case class {{classname}}(
/* {{{description}}} */
{{/description}}
{{name}}: {{#isEnum}}{{^required}}Option[{{/required}}{{classname}}.{{datatypeWithEnum}}{{^required}}]{{/required}}{{/isEnum}}{{^isEnum}}{{{vendorExtensions.x-datatype-model}}}{{/isEnum}}{{^required}} = {{{vendorExtensions.x-defaultValue-model}}} {{/required}}{{^-last}},{{/-last}}
{{/vars}}

{{/vars}}) {
{{#isAdditionalPropertiesTrue}}, additionalProperties : ujson.Value = ujson.Null{{/isAdditionalPropertiesTrue}}
) {
def asJson: String = asData.asJson
def asJsonString: String = asData.asJsonString
def asJson: ujson.Value = asData.asJson
def asData : {{classname}}Data = {
{{classname}}Data(
{{#vars}}
{{name}} = {{name}}{{#vendorExtensions.x-map-asModel}}.map(_.asData){{/vendorExtensions.x-map-asModel}}{{#vendorExtensions.x-wrap-in-optional}}.getOrElse({{{defaultValue}}}){{/vendorExtensions.x-wrap-in-optional}}{{^-last}},{{/-last}}
{{/vars}}
{{#isAdditionalPropertiesTrue}}, additionalProperties{{/isAdditionalPropertiesTrue}}
)
}

}

object {{classname}}{
given RW[{{classname}}] = {{classname}}Data.readWriter.bimap[{{classname}}](_.asData, _.asModel)
object {{classname}} {
given RW[{{classname}}] = summon[RW[ujson.Value]].bimap[{{classname}}](_.asJson, json => read[{{classname}}Data](json).asModel)

enum Fields(fieldName : String) extends Field(fieldName) {
enum Fields(val fieldName : String) extends Field(fieldName) {
{{#vars}}
case {{name}} extends Fields("{{name}}")
{{/vars}}
Expand Down
Loading

0 comments on commit 31be9b9

Please sign in to comment.