Skip to content

Commit 5214309

Browse files
authored
Merge pull request UdashFramework#661 from UdashFramework/optional-params
Optional query/header/cookie/body parameter support
2 parents 32d2d64 + b03a4a3 commit 5214309

File tree

11 files changed

+149
-33
lines changed

11 files changed

+149
-33
lines changed

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:

project/Dependencies.scala

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ object Dependencies {
1616
val scalaCssVersion = "0.5.6"
1717

1818
val servletVersion = "4.0.1"
19-
val avsCommonsVersion = "1.46.2"
19+
val avsCommonsVersion = "1.47.1"
2020

2121
val atmosphereJSVersion = "2.3.9"
2222
val atmosphereVersion = "2.6.1"

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: 53 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
/**
@@ -240,6 +241,58 @@ class Cookie(@defaultsToName override val name: String = RestParamTag.paramName)
240241
class Body(@defaultsToName override val name: String = RestParamTag.paramName)
241242
extends rpcName(name) with RestParamTag
242243

244+
/**
245+
* Like [[Query]] but indicates that the parameter is optional.
246+
* This means that its type must be wrapped into an `Option`, `Opt`, `OptArg`, etc. and empty value
247+
* corresponds to an absence of the parameter in the request.
248+
*
249+
* Also, the macro engine looks for serialization and other implicits directly for the type wrapped in an
250+
* `Opt`, `Option`, e.g. `AsRaw/AsReal[PlainValue, Something]` is needed when an optional query parameter has
251+
* type `Opt[Something]`.
252+
*/
253+
class OptQuery(@defaultsToName name: String = RestParamTag.paramName) extends AnnotationAggregate {
254+
@Query(name) @optionalParam type Implied
255+
}
256+
257+
/**
258+
* Like [[Header]] but indicates that the parameter is optional.
259+
* This means that its type must be wrapped into an `Option`, `Opt`, `OptArg`, etc. and empty value
260+
* corresponds to an absence of the header in the request.
261+
*
262+
* Also, the macro engine looks for serialization and other implicits directly for the type wrapped in an
263+
* `Opt`, `Option`, e.g. `AsRaw/AsReal[PlainValue, Something]` is needed when an optional header parameter has
264+
* type `Opt[Something]`.
265+
*/
266+
class OptHeader(name: String) extends AnnotationAggregate {
267+
@Header(name) @optionalParam type Implied
268+
}
269+
270+
/**
271+
* Like [[Cookie]] but indicates that the parameter is optional.
272+
* This means that its type must be wrapped into an `Option`, `Opt`, `OptArg`, etc. and empty value
273+
* corresponds to an absence of the parameter in the request.
274+
*
275+
* Also, the macro engine looks for serialization and other implicits directly for the type wrapped in an
276+
* `Opt`, `Option`, e.g. `AsRaw/AsReal[PlainValue, Something]` is needed when an optional cookie parameter has
277+
* type `Opt[Something]`.
278+
*/
279+
class OptCookie(@defaultsToName name: String = RestParamTag.paramName) extends AnnotationAggregate {
280+
@Cookie(name) @optionalParam type Implied
281+
}
282+
283+
/**
284+
* Like [[Body]] (for body field parameters) but indicates that the parameter is optional.
285+
* This means that its type must be wrapped into an `Option`, `Opt`, `OptArg`, etc. and empty value
286+
* corresponds to an absence of the field in the request body.
287+
*
288+
* Also, the macro engine looks for serialization and other implicits directly for the type wrapped in an
289+
* `Opt`, `Option`, e.g. `AsRaw/AsReal[JsonValue, Something]` is needed when an optional field has
290+
* type `Opt[Something]`.
291+
*/
292+
class OptBodyField(@defaultsToName name: String = RestParamTag.paramName) extends AnnotationAggregate {
293+
@Body(name) @optionalParam type Implied
294+
}
295+
243296
/**
244297
* Base trait for annotations which may be applied on REST API methods (including prefix methods)
245298
* 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](
@@ -251,7 +262,7 @@ final case class OpenApiParameter[T](
251262
}
252263
val pathParam = in == Location.Path
253264
val param = Parameter(info.name, in,
254-
required = pathParam || !info.hasFallbackValue,
265+
required = pathParam || !info.isOptional,
255266
schema = info.schema(resolver, withDefaultValue = !pathParam),
256267
// repeated query/cookie/header params are not supported for now so ensure `explode` is never assumed to be true
257268
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
@@ -66,17 +66,26 @@ trait RestTestApi {
6666
@pathDescription("path with a followed by b")
6767
@description("A really complex GET operation")
6868
@GET("multi/param") def complexGet(
69-
@Path("p1") p1: Int, @description("Very serious path parameter") @title("Stri") @Path p2: String,
70-
@Header("X-H1") h1: Int, @Header("X-H2") h2: String,
71-
q1: Int, @Query("q=2") @whenAbsent("q2def") q2: String = whenAbsent.value,
72-
@Cookie c1: Int, @Cookie("coo") c2: String
69+
@Path("p1") p1: Int,
70+
@description("Very serious path parameter") @title("Stri") @Path p2: String,
71+
@Header("X-H1") h1: Int,
72+
@Header("X-H2") h2: String,
73+
q1: Int,
74+
@Query("q=2") @whenAbsent("q2def") q2: String = whenAbsent.value,
75+
@OptQuery @whenAbsent(Opt(42)) q3: Opt[Int], // @whenAbsent value must be completely ignored in this case
76+
@Cookie c1: Int,
77+
@Cookie("coo") c2: String
7378
): Future[RestEntity]
7479

7580
@POST("multi/param") def multiParamPost(
76-
@Path("p1") p1: Int, @Path p2: String,
77-
@Header("X-H1") h1: Int, @Header("X-H2") h2: String,
78-
@Query q1: Int, @Query("q=2") q2: String,
79-
b1: Int, @Body("b\"2") @description("weird body field") b2: String
81+
@Path("p1") p1: Int,
82+
@Path p2: String,
83+
@Header("X-H1") h1: Int,
84+
@Header("X-H2") h2: String,
85+
@Query q1: Int,
86+
@Query("q=2") q2: String,
87+
b1: Int,
88+
@Body("b\"2") @description("weird body field") b2: String
8089
): Future[RestEntity]
8190

8291
@CustomBody
@@ -125,8 +134,8 @@ object RestTestApi extends DefaultRestApiCompanion[RestTestApi] {
125134
def moreFailingGet: Future[Unit] = throw HttpErrorException(503, "nie")
126135
def neverGet: Future[Unit] = Promise[Unit].future // Future.never if it wasn't for Scala 2.11
127136
def getEntity(id: RestEntityId): Future[RestEntity] = Future.successful(RestEntity(id, s"${id.value}-name"))
128-
def complexGet(p1: Int, p2: String, h1: Int, h2: String, q1: Int, q2: String, c1: Int, c2: String): Future[RestEntity] =
129-
Future.successful(RestEntity(RestEntityId(s"$p1-$h1-$q1-$c1"), s"$p2-$h2-$q2-$c2"))
137+
def complexGet(p1: Int, p2: String, h1: Int, h2: String, q1: Int, q2: String, q3: Opt[Int], c1: Int, c2: String): Future[RestEntity] =
138+
Future.successful(RestEntity(RestEntityId(s"$p1-$h1-$q1-$c1"), s"$p2-$h2-$q2-${q3.getOrElse(".")}-$c2"))
130139
def multiParamPost(p1: Int, p2: String, h1: Int, h2: String, q1: Int, q2: String, b1: Int, b2: String): Future[RestEntity] =
131140
Future.successful(RestEntity(RestEntityId(s"$p1-$h1-$q1-$b1"), s"$p2-$h2-$q2-$b2"))
132141
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
@@ -25,6 +25,7 @@ class RestSchemaTest extends FunSuite {
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 FunSuite {
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)