Skip to content

Commit 7df7d43

Browse files
author
ghik
committed
Merge branch '0.8.x'
# Conflicts: # project/Dependencies.scala # project/build.properties # project/plugins.sbt # rest/src/main/scala/io/udash/rest/openapi/OpenApiMetadata.scala # rest/src/test/scala/io/udash/rest/RestTestApi.scala
2 parents a761a0b + 5214309 commit 7df7d43

File tree

11 files changed

+154
-33
lines changed

11 files changed

+154
-33
lines changed

guide/backend/src/main/scala/io/udash/web/guide/markdown/MarkdownPagesEndpoint.scala

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package io.udash.web.guide.markdown
22

33
import java.io.{BufferedReader, File, FileReader}
4+
import java.nio.charset.StandardCharsets
45
import java.time.Instant
56
import java.util.concurrent.ConcurrentHashMap
67

@@ -19,7 +20,7 @@ final class MarkdownPagesEndpoint(guideResourceBase: String)(implicit ec: Execut
1920
private val renderedPages = new ConcurrentHashMap[MarkdownPage, (Future[String], Instant)]
2021

2122
private def render(file: File): Future[String] = Future {
22-
val reader = new BufferedReader(new FileReader(file))
23+
val reader = new BufferedReader(new FileReader(file, StandardCharsets.UTF_8))
2324
val document = parser.parseReader(reader)
2425
renderer.render(document)
2526
}

guide/guide/.js/src/main/assets/pages/rest.md

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -441,13 +441,13 @@ explicitly. However, the only reason to use it explicitly is in order to customi
441441
Body parameters are serialized into `JsonValue` objects.
442442
See [serialization](#body-parameter-serialization) for more details.
443443

444-
#### `@FormBody`
444+
##### `@FormBody`
445445

446446
Non-`GET` methods may be annotated with `@FormBody`. This changes serialization of body parameters
447447
from JSON object to HTTP form, encoded as `application/x-www-form-urlencoded`. Each body parameter
448448
is then serialized into `PlainValue` rather than `JsonValue`.
449449

450-
#### `@CustomBody`
450+
##### `@CustomBody`
451451

452452
Methods annotated with `@CustomBody` are required to take **exactly one** body parameter. This body parameter will
453453
be then serialized directly into `HttpBody`. This makes it possible to fully customize the way HTTP body is built.
@@ -460,6 +460,32 @@ object User extends RestDataCompanion[User]
460460
@PUT @CustomBody def updateUser(user: User): Future[Unit]
461461
```
462462

463+
#### Optional parameters
464+
465+
Instead of `@Query`, `@Header`, `@Cookie` and `@Body`, you can also use `@OptQuery`, `@OptHeader`, `@OptCookie`
466+
and `@OptBodyField` to make your parameters explicitly optional. The type of such a parameter must be wrapped into
467+
an `Option`, `Opt`, `OptArg` or similar option-like wrapper, i.e.
468+
469+
```scala
470+
@GET def pageContent(@OptQuery lang: Opt[String]): Future[String]
471+
```
472+
473+
When `Opt.Empty` is passed, this query parameter will simply be omitted in the request.
474+
475+
Note how this is different from simply declaring a (non-optional) parameter typed as `Opt[String]`:
476+
477+
```scala
478+
@GET def pageContent(@Query lang: Opt[String]): Future[String]
479+
```
480+
481+
In the example above the framework will simply try to encode `Opt[String]` into
482+
`PlainValue` (see [serialization](#path-query-header-and-cookie-serialization) for more details).
483+
This will fail because an `Opt[String]` can't be unambiguously represented as a plain string - compilation
484+
will fail because of lack of appropriate implicit (`AsRaw/AsReal[PlainValue, Opt[String]]`).
485+
486+
However, when the parameter is explicitly optional, the framework knows that the `Opt` is used to express
487+
possible lack of this parameter while the actual type of that parameter (that needs to be serialized) is `String`.
488+
463489
### Prefix methods
464490

465491
Prefix methods are methods that return other REST API traits. They are useful for:

rest/.jvm/src/test/resources/RestTestApi.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -335,6 +335,15 @@
335335
"default": "q2def"
336336
}
337337
},
338+
{
339+
"name": "q3",
340+
"in": "query",
341+
"explode": false,
342+
"schema": {
343+
"type": "integer",
344+
"format": "int32"
345+
}
346+
},
338347
{
339348
"name": "c1",
340349
"in": "cookie",

rest/src/main/scala/io/udash/rest/annotations.scala

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ package rest
44
import com.avsystem.commons.annotation.{AnnotationAggregate, defaultsToName}
55
import com.avsystem.commons.meta.RealSymAnnotation
66
import com.avsystem.commons.rpc._
7+
import com.avsystem.commons.serialization.optionalParam
78
import io.udash.rest.raw._
89

910
import scala.annotation.StaticAnnotation
@@ -247,6 +248,62 @@ class Cookie(@defaultsToName override val name: String = RestParamTag.paramName)
247248
class Body(@defaultsToName override val name: String = RestParamTag.paramName)
248249
extends rpcName(name) with RestParamTag
249250

251+
/**
252+
* Like [[Query]] but indicates that the parameter is optional.
253+
* This means that its type must be wrapped into an `Option`, `Opt`, `OptArg`, etc. and empty value
254+
* corresponds to an absence of the parameter in the request.
255+
*
256+
* Also, the macro engine looks for serialization and other implicits directly for the type wrapped in an
257+
* `Opt`, `Option`, e.g. `AsRaw/AsReal[PlainValue, Something]` is needed when an optional query parameter has
258+
* type `Opt[Something]`.
259+
*/
260+
class OptQuery(@defaultsToName name: String = RestParamTag.paramName) extends AnnotationAggregate {
261+
@Query(name) @optionalParam
262+
final def aggregated: List[StaticAnnotation] = reifyAggregated
263+
}
264+
265+
/**
266+
* Like [[Header]] but indicates that the parameter is optional.
267+
* This means that its type must be wrapped into an `Option`, `Opt`, `OptArg`, etc. and empty value
268+
* corresponds to an absence of the header in the request.
269+
*
270+
* Also, the macro engine looks for serialization and other implicits directly for the type wrapped in an
271+
* `Opt`, `Option`, e.g. `AsRaw/AsReal[PlainValue, Something]` is needed when an optional header parameter has
272+
* type `Opt[Something]`.
273+
*/
274+
class OptHeader(name: String) extends AnnotationAggregate {
275+
@Header(name) @optionalParam
276+
final def aggregated: List[StaticAnnotation] = reifyAggregated
277+
}
278+
279+
/**
280+
* Like [[Cookie]] but indicates that the parameter is optional.
281+
* This means that its type must be wrapped into an `Option`, `Opt`, `OptArg`, etc. and empty value
282+
* corresponds to an absence of the parameter in the request.
283+
*
284+
* Also, the macro engine looks for serialization and other implicits directly for the type wrapped in an
285+
* `Opt`, `Option`, e.g. `AsRaw/AsReal[PlainValue, Something]` is needed when an optional cookie parameter has
286+
* type `Opt[Something]`.
287+
*/
288+
class OptCookie(@defaultsToName name: String = RestParamTag.paramName) extends AnnotationAggregate {
289+
@Cookie(name) @optionalParam
290+
final def aggregated: List[StaticAnnotation] = reifyAggregated
291+
}
292+
293+
/**
294+
* Like [[Body]] (for body field parameters) but indicates that the parameter is optional.
295+
* This means that its type must be wrapped into an `Option`, `Opt`, `OptArg`, etc. and empty value
296+
* corresponds to an absence of the field in the request body.
297+
*
298+
* Also, the macro engine looks for serialization and other implicits directly for the type wrapped in an
299+
* `Opt`, `Option`, e.g. `AsRaw/AsReal[JsonValue, Something]` is needed when an optional field has
300+
* type `Opt[Something]`.
301+
*/
302+
class OptBodyField(@defaultsToName name: String = RestParamTag.paramName) extends AnnotationAggregate {
303+
@Body(name) @optionalParam
304+
final def aggregated: List[StaticAnnotation] = reifyAggregated
305+
}
306+
250307
/**
251308
* Base trait for annotations which may be applied on REST API methods (including prefix methods)
252309
* in order to customize outgoing request on the client side.

rest/src/main/scala/io/udash/rest/openapi/OpenApiMetadata.scala

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@ package io.udash
22
package rest.openapi
33

44
import com.avsystem.commons._
5+
import com.avsystem.commons.annotation.bincompat
56
import com.avsystem.commons.meta._
67
import com.avsystem.commons.rpc._
8+
import com.avsystem.commons.serialization.optionalParam
79
import io.udash.rest.openapi.adjusters._
810
import io.udash.rest.raw._
911
import io.udash.rest.{Header => HeaderAnnot, _}
@@ -44,7 +46,6 @@ final case class OpenApiMetadata[T](
4446
@tagged[BodyMethodTag](whenUntagged = new POST)
4547
@tagged[SomeBodyTag](whenUntagged = new JsonBody)
4648
@paramTag[RestParamTag](defaultTag = new Body)
47-
@paramTag[RestParamTag](defaultTag = new Body)
4849
@unmatched(RawRest.NotValidHttpMethod)
4950
bodyMethods: List[OpenApiBodyOperation[_]]
5051
) {
@@ -115,7 +116,7 @@ final case class PathOperation(
115116
sealed trait OpenApiMethod[T] extends TypedMetadata[T] {
116117
@reifyName(useRawName = true) def name: String
117118
@reifyAnnot def methodTag: RestMethodTag
118-
@multi @rpcParamMetadata @tagged[NonBodyTag] def parameters: List[OpenApiParameter[_]]
119+
@multi @rpcParamMetadata @tagged[NonBodyTag] @allowOptional def parameters: List[OpenApiParameter[_]]
119120
@multi @reifyAnnot def operationAdjusters: List[OperationAdjuster]
120121
@multi @reifyAnnot def pathAdjusters: List[PathItemAdjuster]
121122

@@ -203,15 +204,15 @@ final case class OpenApiBodyOperation[T](
203204
operationAdjusters: List[OperationAdjuster],
204205
pathAdjusters: List[PathItemAdjuster],
205206
parameters: List[OpenApiParameter[_]],
206-
@multi @rpcParamMetadata @tagged[Body] bodyFields: List[OpenApiBodyField[_]],
207+
@multi @rpcParamMetadata @tagged[Body] @allowOptional bodyFields: List[OpenApiBodyField[_]],
207208
@reifyAnnot bodyTypeTag: BodyTypeTag,
208209
resultType: RestResultType[T]
209210
) extends OpenApiOperation[T] {
210211

211212
def requestBody(resolver: SchemaResolver): Opt[RefOr[RequestBody]] =
212213
if (bodyFields.isEmpty) Opt.Empty else {
213214
val fields = bodyFields.iterator.map(p => (p.info.name, p.schema(resolver))).toList
214-
val requiredFields = bodyFields.collect { case p if !p.info.hasFallbackValue => p.info.name }
215+
val requiredFields = bodyFields.collect { case p if !p.info.isOptional => p.info.name }
215216
val schema = Schema(`type` = DataType.Object, properties = IListMap(fields: _*), required = requiredFields)
216217
val mediaType = bodyTypeTag match {
217218
case _: JsonBody => HttpBody.JsonType
@@ -226,14 +227,24 @@ final case class OpenApiBodyOperation[T](
226227
final case class OpenApiParamInfo[T](
227228
@reifyName(useRawName = true) name: String,
228229
@optional @composite whenAbsentInfo: Opt[WhenAbsentInfo[T]],
230+
@isAnnotated[optionalParam] optional: Boolean,
229231
@reifyFlags flags: ParamFlags,
230232
@infer restSchema: RestSchema[T]
231233
) extends TypedMetadata[T] {
232-
val whenAbsentValue: Opt[JsonValue] = whenAbsentInfo.flatMap(_.fallbackValue)
233-
val hasFallbackValue: Boolean = whenAbsentInfo.fold(flags.hasDefaultValue)(_.fallbackValue.isDefined)
234+
val whenAbsentValue: Opt[JsonValue] =
235+
if (optional) Opt.Empty
236+
else whenAbsentInfo.flatMap(_.fallbackValue)
237+
238+
val isOptional: Boolean = optional || flags.hasDefaultValue || whenAbsentValue.isDefined
239+
240+
@bincompat private[rest] def hasFallbackValue: Boolean = isOptional
234241

235242
def schema(resolver: SchemaResolver, withDefaultValue: Boolean): RefOr[Schema] =
236243
resolver.resolve(restSchema) |> (s => if (withDefaultValue) s.withDefaultValue(whenAbsentValue) else s)
244+
245+
@bincompat private[rest] def this(
246+
name: String, whenAbsentInfo: Opt[WhenAbsentInfo[T]], flags: ParamFlags, restSchema: RestSchema[T]
247+
) = this(name, whenAbsentInfo, false, flags, restSchema)
237248
}
238249

239250
final case class OpenApiParameter[T](
@@ -253,7 +264,7 @@ final case class OpenApiParameter[T](
253264
val urlEncodeName = in == Location.Query || in == Location.Cookie
254265
val name = if(urlEncodeName) URLEncoder.encode(info.name, spaceAsPlus = true) else info.name
255266
val param = Parameter(name, in,
256-
required = pathParam || !info.hasFallbackValue,
267+
required = pathParam || !info.isOptional,
257268
schema = info.schema(resolver, withDefaultValue = !pathParam),
258269
// repeated query/cookie/header params are not supported for now so ensure `explode` is never assumed to be true
259270
explode = if (in.defaultStyle.explodeByDefault) OptArg(false) else OptArg.Empty

rest/src/main/scala/io/udash/rest/openapi/RestStructure.scala

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ object RestStructure extends AdtMetadataCompanion[RestStructure] {
9292
*/
9393
@positioned(positioned.here) final case class Record[T](
9494
@multi @reifyAnnot schemaAdjusters: List[SchemaAdjuster],
95-
@adtParamMetadata @multi fields: List[Field[_]],
95+
@adtParamMetadata @allowOptional @multi fields: List[Field[_]],
9696
@composite info: GenCaseInfo[T]
9797
) extends RestStructure[T] with Case[T] {
9898

@@ -110,7 +110,7 @@ object RestStructure extends AdtMetadataCompanion[RestStructure] {
110110
val props = caseFieldName.map(cfn => (cfn, RefOr(Schema.enumOf(List(info.rawName))))).iterator ++
111111
fields.iterator.map(f => (f.info.rawName, f.resolveSchema(resolver)))
112112
val required = caseFieldName.iterator ++
113-
fields.iterator.filterNot(_.hasFallbackValue).map(_.info.rawName)
113+
fields.iterator.filterNot(_.optional).map(_.info.rawName)
114114
RefOr(applyAdjusters(Schema(`type` = DataType.Object,
115115
properties = IListMap(props.toList: _*),
116116
required = required.toList
@@ -151,8 +151,10 @@ object RestStructure extends AdtMetadataCompanion[RestStructure] {
151151
) extends TypedMetadata[T] {
152152

153153
val fallbackValue: Opt[JsonValue] =
154-
(whenAbsentInfo.map(_.fallbackValue) orElse defaultValueInfo.map(_.fallbackValue)).flatten
155-
val hasFallbackValue: Boolean = fallbackValue.isDefined
154+
if (info.optional) Opt.Empty
155+
else (whenAbsentInfo.map(_.fallbackValue) orElse defaultValueInfo.map(_.fallbackValue)).flatten
156+
157+
val optional: Boolean = info.optional || fallbackValue.isDefined
156158

157159
def resolveSchema(resolver: SchemaResolver): RefOr[Schema] = {
158160
val bareSchema = resolver.resolve(restSchema).withDefaultValue(fallbackValue)

rest/src/main/scala/io/udash/rest/raw/RestMetadata.scala

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -280,7 +280,7 @@ final case class HttpMethodMetadata[T](
280280
@reifyAnnot methodTag: HttpMethodTag,
281281
@reifyAnnot bodyTypeTag: BodyTypeTag,
282282
@composite parametersMetadata: RestParametersMetadata,
283-
@multi @tagged[Body] @rpcParamMetadata bodyParams: List[ParamMetadata[_]],
283+
@multi @tagged[Body] @rpcParamMetadata @allowOptional bodyParams: List[ParamMetadata[_]],
284284
@isAnnotated[FormBody] formBody: Boolean,
285285
@multi @reifyAnnot requestAdjusters: List[RequestAdjuster],
286286
@multi @reifyAnnot responseAdjusters: List[ResponseAdjuster],
@@ -322,9 +322,9 @@ object HttpResponseType {
322322

323323
final case class RestParametersMetadata(
324324
@multi @tagged[Path] @rpcParamMetadata pathParams: List[PathParamMetadata[_]],
325-
@multi @tagged[Header] @rpcParamMetadata headerParams: List[ParamMetadata[_]],
326-
@multi @tagged[Query] @rpcParamMetadata queryParams: List[ParamMetadata[_]],
327-
@multi @tagged[Cookie] @rpcParamMetadata cookieParams: List[ParamMetadata[_]]
325+
@multi @tagged[Header] @rpcParamMetadata @allowOptional headerParams: List[ParamMetadata[_]],
326+
@multi @tagged[Query] @rpcParamMetadata @allowOptional queryParams: List[ParamMetadata[_]],
327+
@multi @tagged[Cookie] @rpcParamMetadata @allowOptional cookieParams: List[ParamMetadata[_]]
328328
) {
329329
lazy val headerParamsMap: Map[String, ParamMetadata[_]] =
330330
headerParams.toMapBy(_.name.toLowerCase)

rest/src/main/scala/io/udash/rest/raw/RestRequest.scala

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,9 @@ object HttpMethod extends AbstractValueEnumCompanion[HttpMethod] {
2222
*/
2323
final case class RestParameters(
2424
@multi @tagged[Path] path: List[PlainValue] = Nil,
25-
@multi @tagged[Header] headers: IMapping[PlainValue] = IMapping.empty,
26-
@multi @tagged[Query] query: Mapping[PlainValue] = Mapping.empty,
27-
@multi @tagged[Cookie] cookies: Mapping[PlainValue] = Mapping.empty
25+
@multi @tagged[Header] @allowOptional headers: IMapping[PlainValue] = IMapping.empty,
26+
@multi @tagged[Query] @allowOptional query: Mapping[PlainValue] = Mapping.empty,
27+
@multi @tagged[Cookie] @allowOptional cookies: Mapping[PlainValue] = Mapping.empty
2828
) {
2929
def append(method: RestMethodMetadata[_], otherParameters: RestParameters): RestParameters =
3030
RestParameters(

rest/src/test/scala/io/udash/rest/RestApiTest.scala

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,8 @@ trait RestApiTestScenarios extends RestApiTest {
4343
}
4444

4545
test("complex GET") {
46-
testCall(_.complexGet(0, "a/ +&", 1, "b/ +&", 2, "ć/ +&", 3, "ó /&f"))
46+
testCall(_.complexGet(0, "a/ +&", 1, "b/ +&", 2, "ć/ +&", Opt(3), 4, "ó /&f"))
47+
testCall(_.complexGet(0, "a/ +&", 1, "b/ +&", 2, "ć/ +&", Opt.Empty, 3, "ó /&f"))
4748
}
4849

4950
test("multi-param body POST") {

rest/src/test/scala/io/udash/rest/RestTestApi.scala

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -77,17 +77,26 @@ trait RestTestApi {
7777
@pathDescription("path with a followed by b")
7878
@description("A really complex GET operation")
7979
@GET("multi/param") def complexGet(
80-
@Path("p1") p1: Int, @description("Very serious path parameter") @title("Stri") @Path p2: String,
81-
@Header("X-H1") h1: Int, @Header("X-H2") h2: String,
82-
q1: Int, @Query("q=2") @whenAbsent("q2def") q2: String = whenAbsent.value,
83-
@Cookie c1: Int, @Cookie("") c2: String
80+
@Path("p1") p1: Int,
81+
@description("Very serious path parameter") @title("Stri") @Path p2: String,
82+
@Header("X-H1") h1: Int,
83+
@Header("X-H2") h2: String,
84+
q1: Int,
85+
@Query("q=2") @whenAbsent("q2def") q2: String = whenAbsent.value,
86+
@OptQuery @whenAbsent(Opt(42)) q3: Opt[Int], // @whenAbsent value must be completely ignored in this case
87+
@Cookie c1: Int,
88+
@Cookie("") c2: String
8489
): Future[RestEntity]
8590

8691
@POST("multi/param") def multiParamPost(
87-
@Path("p1") p1: Int, @Path p2: String,
88-
@Header("X-H1") h1: Int, @Header("X-H2") h2: String,
89-
@Query q1: Int, @Query("q=2") q2: String,
90-
b1: Int, @Body("b\"2") @description("weird body field") b2: String
92+
@Path("p1") p1: Int,
93+
@Path p2: String,
94+
@Header("X-H1") h1: Int,
95+
@Header("X-H2") h2: String,
96+
@Query q1: Int,
97+
@Query("q=2") q2: String,
98+
b1: Int,
99+
@Body("b\"2") @description("weird body field") b2: String
91100
): Future[RestEntity]
92101

93102
@CustomBody
@@ -137,8 +146,8 @@ object RestTestApi extends DefaultRestApiCompanion[RestTestApi] {
137146
def moreFailingGet: Future[Unit] = throw HttpErrorException(503, "nie")
138147
def neverGet: Future[Unit] = Future.never
139148
def getEntity(id: RestEntityId): Future[RestEntity] = Future.successful(RestEntity(id, s"${id.value}-name"))
140-
def complexGet(p1: Int, p2: String, h1: Int, h2: String, q1: Int, q2: String, c1: Int, c2: String): Future[RestEntity] =
141-
Future.successful(RestEntity(RestEntityId(s"$p1-$h1-$q1-$c1"), s"$p2-$h2-$q2-$c2"))
149+
def complexGet(p1: Int, p2: String, h1: Int, h2: String, q1: Int, q2: String, q3: Opt[Int], c1: Int, c2: String): Future[RestEntity] =
150+
Future.successful(RestEntity(RestEntityId(s"$p1-$h1-$q1-$c1"), s"$p2-$h2-$q2-${q3.getOrElse(".")}-$c2"))
142151
def multiParamPost(p1: Int, p2: String, h1: Int, h2: String, q1: Int, q2: String, b1: Int, b2: String): Future[RestEntity] =
143152
Future.successful(RestEntity(RestEntityId(s"$p1-$h1-$q1-$b1"), s"$p2-$h2-$q2-$b2"))
144153
def singleBodyPut(entity: RestEntity): Future[String] =

rest/src/test/scala/io/udash/rest/openapi/RestSchemaTest.scala

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ package rest.openapi
44
import com.avsystem.commons._
55
import com.avsystem.commons.misc.{AbstractValueEnum, AbstractValueEnumCompanion, EnumCtx}
66
import com.avsystem.commons.serialization.json.JsonStringOutput
7-
import com.avsystem.commons.serialization.{GenCodec, name, transparent}
7+
import com.avsystem.commons.serialization.{GenCodec, name, optionalParam, transparent}
88
import io.udash.rest.openapi.adjusters.description
99
import io.udash.rest.{PolyRestDataCompanion, RestDataCompanion}
1010
import org.scalatest.funsuite.AnyFunSuite
@@ -25,6 +25,7 @@ class RestSchemaTest extends AnyFunSuite {
2525
case class KejsKlass(
2626
@name("integer") @customWa(42) int: Int,
2727
@description("serious dependency") dep: Dependency,
28+
@description("optional thing") @optionalParam opty: Opt[String] = Opt("defaultThatMustBeIgnored"),
2829
@description("serious string") str: Opt[String] = Opt.Empty
2930
)
3031
object KejsKlass extends RestDataCompanion[KejsKlass]
@@ -48,6 +49,10 @@ class RestSchemaTest extends AnyFunSuite {
4849
| }
4950
| ]
5051
| },
52+
| "opty": {
53+
| "type": "string",
54+
| "description": "optional thing"
55+
| },
5156
| "str": {
5257
| "type": "string",
5358
| "description": "serious string",

0 commit comments

Comments
 (0)