Skip to content

Commit

Permalink
Merge pull request softwaremill#2606 from korlowski/master
Browse files Browse the repository at this point in the history
Add support for JSON query params
  • Loading branch information
adamw committed Dec 16, 2022
2 parents 4e3c004 + 81b3797 commit 2759666
Show file tree
Hide file tree
Showing 17 changed files with 122 additions and 16 deletions.
6 changes: 3 additions & 3 deletions core/src/main/scala/sttp/tapir/EndpointIO.scala
Original file line number Diff line number Diff line change
Expand Up @@ -196,11 +196,11 @@ object EndpointInput extends EndpointInputMacros {
override def show = s"/*"
}

case class Query[T](name: String, flagValue: Option[T], codec: Codec[List[String], T, TextPlain], info: Info[T]) extends Atom[T] {
case class Query[T](name: String, flagValue: Option[T], codec: Codec[List[String], T, CodecFormat], info: Info[T]) extends Atom[T] {
override private[tapir] type ThisType[X] = Query[X]
override private[tapir] type L = List[String]
override private[tapir] type CF = TextPlain
override private[tapir] def copyWith[U](c: Codec[List[String], U, TextPlain], i: Info[U]): Query[U] =
override private[tapir] type CF = CodecFormat
override private[tapir] def copyWith[U](c: Codec[List[String], U, CodecFormat], i: Info[U]): Query[U] =
copy(flagValue = flagValue.map(t => c.decode(codec.encode(t))).collect { case DecodeResult.Value(u) => u }, codec = c, info = i)
override def show: String = addValidatorShow(s"?$name", codec.schema)

Expand Down
5 changes: 4 additions & 1 deletion core/src/main/scala/sttp/tapir/Tapir.scala
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,11 @@ trait Tapir extends TapirExtensions with TapirComputedInputs with TapirStaticCon
EndpointInput.PathCapture(Some(name), implicitly, EndpointIO.Info.empty)
def paths: EndpointInput.PathsCapture[List[String]] = EndpointInput.PathsCapture(Codec.idPlain(), EndpointIO.Info.empty)

/** A query parameter in any format, read using the given `codec`. */
def queryAnyFormat[T, CF <: CodecFormat](name: String, codec: Codec[List[String], T, CF]): EndpointInput.Query[T] =
EndpointInput.Query(name, None, codec, EndpointIO.Info.empty)
def query[T: Codec[List[String], *, TextPlain]](name: String): EndpointInput.Query[T] =
EndpointInput.Query(name, None, implicitly, EndpointIO.Info.empty)
queryAnyFormat[T, TextPlain](name, implicitly)
def queryParams: EndpointInput.QueryParams[QueryParams] = EndpointInput.QueryParams(Codec.idPlain(), EndpointIO.Info.empty)

def header[T: Codec[List[String], *, TextPlain]](name: String): EndpointIO.Header[T] =
Expand Down
2 changes: 1 addition & 1 deletion core/src/test/scala/sttp/tapir/EndpointTest.scala
Original file line number Diff line number Diff line change
Expand Up @@ -374,7 +374,7 @@ class EndpointTest extends AnyFlatSpec with EndpointTestExtensions with Matchers
// given
case class Wrapper(i: Int)
val mapped = query[Int]("q1").mapTo[Wrapper]
val codec: Codec[List[String], Wrapper, CodecFormat.TextPlain] = mapped.codec
val codec: Codec[List[String], Wrapper, CodecFormat] = mapped.codec

// when
codec.encode(Wrapper(10)) shouldBe (List("10"))
Expand Down
21 changes: 18 additions & 3 deletions doc/endpoint/json.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
Json values are supported through codecs, which encode/decode values to json strings. Most often, you'll be using a
third-party library to perform the actual json parsing/printing. See below for the list of supported libraries.

All the integrations, when imported into scope, define a `jsonBody[T]` method.
All the integrations, when imported into scope, define `jsonBody[T]` and `jsonQuery[T]` methods.

Instead of providing the json codec as an implicit value, this method depends on library-specific implicits being in
scope, and basing on these values creates a json codec. The derivation also requires
Expand Down Expand Up @@ -258,6 +258,21 @@ import sttp.tapir.json.zio._

Zio JSON requires `JsonEncoder` and `JsonDecoder` implicit values in scope for each type you want to serialize.

## JSON query parameters

You can specify query parameters in JSON format by using the `jsonQuery` method. For example, using Circe:

```scala mdoc:compile-only
import sttp.tapir._
import sttp.tapir.json.circe._
import sttp.tapir.generic.auto._
import io.circe.generic.auto._

case class Book(author: String, title: String, year: Int)

val bookQuery: EndpointInput.Query[Book] = jsonQuery[Book]("book")
```

## Other JSON libraries

To add support for additional JSON libraries, see the
Expand All @@ -271,8 +286,8 @@ of [schema derivation](schemas.md) will have to match the configuration of your
have different defaults when it comes to a discrimination strategy, so in order to have the schemas (and hence the
documentation) in sync with how the values are serialised, you will have to configure schema derivation as well.

Schemas are referenced at the point of `jsonBody` usage, so any configuration must be available in the implicit scope
when this method is called.
Schemas are referenced at the point of `jsonBody` and `jsonQuery` usage, so any configuration must be available in the implicit scope
when these methods are called.

## Next

Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
package sttp.tapir.docs.openapi

import sttp.apispec.{ReferenceOr, Schema}
import sttp.apispec.openapi.{Parameter, ParameterIn}
import sttp.apispec.openapi.{MediaType, Parameter, ParameterIn}
import sttp.tapir.docs.apispec.DocsExtensionAttribute.RichEndpointIOInfo
import sttp.tapir.docs.apispec.DocsExtensions
import sttp.tapir.{Codec, EndpointIO, EndpointInput}

import scala.collection.immutable.ListMap

private[openapi] object EndpointInputToParameterConverter {
def from[T](query: EndpointInput.Query[T], schema: ReferenceOr[Schema]): Parameter = {
val examples = ExampleConverter.convertExamples(query.codec, query.info.examples)
Expand All @@ -24,6 +26,19 @@ private[openapi] object EndpointInputToParameterConverter {
)
}

def from[T](query: EndpointInput.Query[T], content: ListMap[String, MediaType]): Parameter =
Parameter(
name = query.name,
in = ParameterIn.Query,
description = query.info.description,
required = Some(!query.codec.schema.isOptional),
deprecated = if (query.info.deprecated) Some(true) else None,
schema = None,
extensions = DocsExtensions.fromIterable(query.info.docsExtensions),
content = content,
allowEmptyValue = query.flagValue.fold(None: Option[Boolean])(_ => Some(true))
)

def from[T](pathCapture: EndpointInput.PathCapture[T], schema: ReferenceOr[Schema]): Parameter = {
val examples = ExampleConverter.convertExamples(pathCapture.codec, pathCapture.info.examples)
Parameter(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,13 @@ private[openapi] class EndpointToOpenAPIPaths(schemas: Schemas, securitySchemes:
EndpointInputToParameterConverter.from(header, Right(ASchema(ASchemaType.String)))
private def cookieToParameter[T](cookie: EndpointInput.Cookie[T]) = EndpointInputToParameterConverter.from(cookie, schemas(cookie.codec))
private def pathCaptureToParameter[T](p: EndpointInput.PathCapture[T]) = EndpointInputToParameterConverter.from(p, schemas(p.codec))
private def queryToParameter[T](query: EndpointInput.Query[T]) = EndpointInputToParameterConverter.from(query, schemas(query.codec))

private def queryToParameter[T](query: EndpointInput.Query[T]) = query.codec.format match {
// use `schema` for simple plain text scenarios and `content` for complex serializations, e.g. JSON
// see https://swagger.io/docs/specification/describing-parameters/#schema-vs-content
case CodecFormat.TextPlain() => EndpointInputToParameterConverter.from(query, schemas(query.codec))
case _ => EndpointInputToParameterConverter.from(query, codecToMediaType(query.codec, query.info.examples, None, Nil))
}

private def enrich(e: EndpointInput.Atom[_], p: Parameter): Parameter = addExplode(e, p)

Expand Down
27 changes: 27 additions & 0 deletions docs/openapi-docs/src/test/resources/expected_json_query_param.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
openapi: 3.0.3
info:
title: Entities
version: '1.0'
paths:
/:
post:
operationId: postRoot
parameters:
- name: name
in: query
required: false
content:
application/json:
schema:
type: string
default: tom
example: alan
responses:
'200':
description: ''
'400':
description: 'Invalid value for: query parameter name'
content:
text/plain:
schema:
type: string
Original file line number Diff line number Diff line change
Expand Up @@ -781,6 +781,20 @@ class VerifyYamlTest extends AnyFunSuite with Matchers {

noIndentation(actualYaml) shouldBe expectedYaml
}

test("should add application/json content for json query parameter") {
val expectedYaml = load("expected_json_query_param.yml")
val codec = Codec.listHead(Codec.json[String](DecodeResult.Value(_))(identity))
val actualYaml = OpenAPIDocsInterpreter()
.toOpenAPI(
endpoint.post.in(queryAnyFormat[String, CodecFormat.Json]("name", codec).example("alan").default("tom")),
Info("Entities", "1.0")
)
.toYaml

val actualYamlNoIndent = noIndentation(actualYaml)
actualYamlNoIndent shouldBe expectedYaml
}
}

object VerifyYamlTest {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ trait TapirJsonCirce {
implicitly[JsonCodec[(String, T)]]
)

def jsonQuery[T: Encoder: Decoder: Schema](name: String): EndpointInput.Query[T] =
queryAnyFormat[T, CodecFormat.Json](name, implicitly)

implicit def circeCodec[T: Encoder: Decoder: Schema]: JsonCodec[T] =
sttp.tapir.Codec.json[T] { s =>
io.circe.parser.decodeAccumulating[T](s) match {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import sttp.tapir.Codec.JsonCodec
import sttp.tapir.DecodeResult.Error.{JsonDecodeException, JsonError}
import sttp.tapir.DecodeResult.{Error, Value}
import sttp.tapir.SchemaType.SCoproduct
import sttp.tapir.{Codec, EndpointIO, Schema, stringBodyUtf8AnyFormat}
import sttp.tapir._

trait TapirJson4s {
def jsonBody[T: Manifest: Schema](implicit formats: Formats, serialization: Serialization): EndpointIO.Body[String, T] =
Expand All @@ -16,6 +16,9 @@ trait TapirJson4s {
implicitly[JsonCodec[(String, T)]]
)

def jsonQuery[T: Manifest: Schema](name: String)(implicit formats: Formats, serialization: Serialization): EndpointInput.Query[T] =
queryAnyFormat[T, CodecFormat.Json](name, implicitly)

implicit def json4sCodec[T: Manifest: Schema](implicit formats: Formats, serialization: Serialization): JsonCodec[T] =
Codec.json[T] { s =>
try {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
package sttp.tapir.json.jsoniter

import com.github.plokhotnyuk.jsoniter_scala.core.{JsonValueCodec, _}
import com.github.plokhotnyuk.jsoniter_scala.core._
import sttp.tapir.Codec.JsonCodec
import sttp.tapir.DecodeResult.Error.JsonDecodeException
import sttp.tapir.DecodeResult.{Error, Value}
import sttp.tapir.{EndpointIO, Schema, stringBodyUtf8AnyFormat}
import sttp.tapir._

import scala.util.{Failure, Success, Try}

Expand All @@ -15,6 +15,9 @@ trait TapirJsonJsoniter {
implicitly[JsonCodec[(String, T)]]
)

def jsonQuery[T: JsonValueCodec: Schema](name: String): EndpointInput.Query[T] =
queryAnyFormat[T, CodecFormat.Json](name, implicitly)

implicit def jsoniterCodec[T: JsonValueCodec: Schema]: JsonCodec[T] =
sttp.tapir.Codec.json { s =>
Try(readFromString[T](s)) match {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ trait TapirJsonPlay {
implicitly[JsonCodec[(String, T)]]
)

def jsonQuery[T: Reads: Writes: Schema](name: String): EndpointInput.Query[T] =
queryAnyFormat[T, CodecFormat.Json](name, implicitly)

implicit def readsWritesCodec[T: Reads: Writes: Schema]: JsonCodec[T] =
Codec.json[T] { s =>
Try(Json.parse(s)) match {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import sttp.tapir.Schema.SName
import sttp.tapir.SchemaType._
import sttp.tapir._

import scala.collection.immutable.ListMap
import scala.util.{Failure, Success, Try}

trait TapirJsonSpray {
Expand All @@ -18,6 +17,9 @@ trait TapirJsonSpray {
implicitly[JsonCodec[(String, T)]]
)

def jsonQuery[T: JsonFormat: Schema](name: String): EndpointInput.Query[T] =
queryAnyFormat[T, CodecFormat.Json](name, implicitly)

implicit def jsonFormatCodec[T: JsonFormat: Schema]: JsonCodec[T] =
Codec.json { s =>
Try(s.parseJson.convertTo[T]) match {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ trait TapirJsonTethys {
implicitly[JsonCodec[(String, T)]]
)

def jsonQuery[T: JsonWriter: JsonReader: Schema](name: String): EndpointInput.Query[T] =
queryAnyFormat[T, CodecFormat.Json](name, implicitly)

implicit def tethysCodec[T: JsonReader: JsonWriter: Schema]: JsonCodec[T] =
Codec.json(s =>
s.jsonAs[T] match {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ trait TapirJsonuPickle {
implicitly[JsonCodec[(String, T)]]
)

def jsonQuery[T: ReadWriter: Schema](name: String): EndpointInput.Query[T] =
queryAnyFormat[T, CodecFormat.Json](name, implicitly)

implicit def readWriterCodec[T: ReadWriter: Schema]: JsonCodec[T] =
Codec.json[T] { s =>
Try(read[T](s)) match {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import sttp.tapir.DecodeResult.Error.{JsonDecodeException, JsonError}
import sttp.tapir.DecodeResult.{Error, Value}
import sttp.tapir.Schema.SName
import sttp.tapir.SchemaType.{SCoproduct, SProduct}
import sttp.tapir.{EndpointIO, FieldName, Schema, stringBodyUtf8AnyFormat}
import sttp.tapir._
import zio.json.ast.Json
import zio.json.ast.Json.Obj
import zio.json.{JsonDecoder, JsonEncoder, _}
Expand All @@ -18,6 +18,9 @@ trait TapirJsonZio {
implicitly[JsonCodec[(String, T)]]
)

def jsonQuery[T: JsonEncoder: JsonDecoder: Schema](name: String): EndpointInput.Query[T] =
queryAnyFormat[T, CodecFormat.Json](name, implicitly)

implicit def zioCodec[T: JsonEncoder: JsonDecoder: Schema]: JsonCodec[T] =
sttp.tapir.Codec.json[T] { s =>
zio.json.JsonDecoder.apply[T].decodeJson(s) match {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import sttp.tapir.DecodeResult.Error.{JsonDecodeException, JsonError}
import sttp.tapir.DecodeResult.{Error, Value}
import sttp.tapir.Schema.SName
import sttp.tapir.SchemaType.{SCoproduct, SProduct}
import sttp.tapir.{EndpointIO, FieldName, Schema, stringBodyUtf8AnyFormat}
import sttp.tapir._
import zio.json.ast.Json
import zio.json.ast.Json.Obj
import zio.json.{JsonDecoder, JsonEncoder, _}
Expand All @@ -18,6 +18,9 @@ trait TapirJsonZio {
implicitly[JsonCodec[(String, T)]]
)

def jsonQuery[T: JsonEncoder: JsonDecoder: Schema](name: String): EndpointInput.Query[T] =
queryAnyFormat[T, CodecFormat.Json](name, implicitly)

implicit def zioCodec[T: JsonEncoder: JsonDecoder: Schema]: JsonCodec[T] =
sttp.tapir.Codec.json[T] { s =>
zio.json.JsonDecoder.apply[T].decodeJson(s) match {
Expand Down

0 comments on commit 2759666

Please sign in to comment.