Custom config endpoint (either HOCON or JSON) - json type is string instead of object

We’ve implemented an endpoint exposing our typesafe config object (simplified from our real code):

// https://github.com/lightbend/config/blob/master/HOCON.md#mime-type
case class Hocon() extends CodecFormat {
  override val mediaType: MediaType = MediaType("application", "hocon")
}

val hoconBody: EndpointIO.Body[String, Config] = EndpointIO.Body(
  bodyType = stringBody.bodyType,
  codec = stringBody.codec.format(Hocon()).map(s => ConfigFactory.parseString(s))(_.root().render()),
  info = EndpointIO.Info.empty)

val parseJSON: String => DecodeResult[Config] = inputString => Try {
  ConfigFactory.parseString(
    inputString,
    ConfigParseOptions
      .defaults()
      .setSyntax(ConfigSyntax.JSON))
}.fold(DecodeResult.Error(inputString, _), DecodeResult.Value.apply)
val formatJSON: Config => String = _.root().render(
  ConfigRenderOptions
    .concise()
    .setJson(true))

and:

// this is probably wrong, but not sure how to fix
val configCustomJsonCodec: JsonCodec[Config] = Codec.json[Config](parseJSON)(formatJSON)(Schema.string[Config])

The endpoint itself is defined by (simplified from original):

val paths: EndpointInput.PathCapture[List[String]] = path[String]
  .name("path")
  .description("Configuration key paths, separated by ','")
  .map(_.split(',').toList)(_.mkString(","))

val configAtPath = customBase
  .name("configAtPath")
  .description("Get configuration for some path(s)")
  .in(paths)
  .get
  .out(oneOfBody(hoconBody, customCodecJsonBody[Config](configCustomJsonCodec)))

and logic is implemented with (also simplified):

val config: Config = …

configAtPath.serverLogicPure(traversePaths(config, _))

def traversePaths(config: Config, paths: List[String]): Either[ManagedError, Config] = {
  val (errors, confs) = paths.partitionWith(getConfig(config, _))
  if (errors.isEmpty) Right(confs.reduce(_ withFallback _))
  else Left(NotFound(errors.mkString("\n")))
}

def getConfig(config: Config, path: String): Either[ManagedError, Config] =
  if (config.hasPath(path)) Right(config.withOnlyPath(path))
  else Left(NotFound(s"'$path' does not exist in configuration."))

The thing is, that the resulting schema has responses section that looks like:

responses:
  '200':
    description: ''
    content:
      application/hocon:
        schema:
          type: string
      application/json:
        schema:
          type: string

And it seems like I should have type: object instead of type: string for the JSON part, but since I used Schema.string[Config] for configCustomJsonCodec it’s probably why it’s a string.
But I couldn’t figure out what to use for a free form JSON object like that.

Is there an easy fix I’m missing here?
Thanks!

This looks correct :slight_smile: For the hoconBody you could probalby use stringBodyUtf8AnyFormat and just provide the codec. configCustomJsonCodec also is correct, that’s what the Codec.json method is for.

A schema for a free-form JSON object is defined as: Schema(SProduct(Nil), None). If you take a look at the integrations source, there are schemas for both arbitrary-json and arbitrary-json-object, which maybe can provide some guidance.

Thanks @adamw,
I’m not convinced I got it right though. I tried something similar to what you suggest:

val schemaForConfigJsonObject: Schema[Config] =
  Schema(SCoproduct(Nil, None)(_ => None), None)

// used schemaForConfigJsonObject instead of Schema.string[Config]
val configCustomJsonCodec: JsonCodec[Config] =
  Codec.json[Config](parseJSON)(formatJSON)(schemaForConfigJsonObject)

Now the resulting schema I get is:

content:
  application/hocon:
    schema:
      type: string
  application/json:
    schema: {}

but according to docs I should aim for:

content:
  application/hocon:
    schema:
      type: string
  application/json:
    schema:
      type: object

see section on free form objects:

as for schema: {}, this means “any type”:

A schema without a type matches any data type – numbers, strings, objects, and so on. {} is shorthand syntax for an arbitrary-type schema

If you use a coproduct that means it’s an arbitrary JSON - anything goes (as far as JSON syntax allows it). Hence it can be an object, or a number.

Schema(SProduct(Nil), None) should be different and produce a schema for a product only.

I tried generating docs for:

endpoint.get.in(customCodecJsonBody(Codec.json[Any](_ => ???)(_ => ???)(Schema(SchemaType.SProduct(Nil), None))))

and I think I got the correct result:

paths:
  /:
    get:
      operationId: getRoot
      requestBody:
        content:
          application/json:
            schema:
              type: object
        required: true

Thanks @adamw ! worked like a charm!
Thank you so so much!!!

I created a PR adding this as a method to Schema and replacing the existing usages: Schemas for any / any object by adamw · Pull Request #2673 · softwaremill/tapir · GitHub

Should be easier now :slight_smile:

1 Like

awesome!!!
thank you!!!

1 Like