All Downloads are FREE. Search and download functionalities are using the official Maven repository.

zio.http.endpoint.openapi.OpenAPIGen.scala Maven / Gradle / Ivy

The newest version!
package zio.http.endpoint.openapi

import java.util.UUID

import scala.annotation.tailrec
import scala.collection.immutable.ListMap
import scala.collection.{immutable, mutable}

import zio._
import zio.json.EncoderOps
import zio.json.ast.Json

import zio.schema.Schema.{Record, Transform}
import zio.schema.codec.JsonCodec
import zio.schema.{Schema, TypeId}

import zio.http._
import zio.http.codec.HttpCodec.Metadata
import zio.http.codec._
import zio.http.endpoint._
import zio.http.endpoint.openapi.JsonSchema.SchemaStyle
import zio.http.endpoint.openapi.OpenAPI.{Path, PathItem}

object OpenAPIGen {
  private val PathWildcard = "pathWildcard"

  private[openapi] def groupMap[A, K, B](chunk: Chunk[A])(key: A => K)(f: A => B): immutable.Map[K, Chunk[B]] = {
    val m = mutable.Map.empty[K, mutable.Builder[B, Chunk[B]]]
    for (elem <- chunk) {
      val k    = key(elem)
      val bldr = m.getOrElseUpdate(k, Chunk.newBuilder[B])
      bldr += f(elem)
    }
    class Result extends runtime.AbstractFunction1[(K, mutable.Builder[B, Chunk[B]]), Unit] {
      var built = immutable.Map.empty[K, Chunk[B]]

      def apply(kv: (K, mutable.Builder[B, Chunk[B]])): Unit =
        built = built.updated(kv._1, kv._2.result())
    }
    val result = new Result
    m.foreach(result)
    result.built
  }

  final case class MetaCodec[T](codec: T, annotations: Chunk[HttpCodec.Metadata[_]]) {
    lazy val docs: Doc = {
      val annotatedDoc    = annotations.foldLeft(Doc.empty) {
        case (doc, HttpCodec.Metadata.Documented(nextDoc)) => doc + nextDoc
        case (doc, _)                                      => doc
      }
      val trailingPathDoc = codec.asInstanceOf[Any] match {
        case SegmentCodec.Trailing =>
          Doc.p(
            Doc.Span.bold("WARNING: This is wildcard path segment. There is no official OpenAPI support for this."),
          ) +
            Doc.p("Tools might URL encode this segment and it might not work as expected.")
        case _                     =>
          Doc.empty
      }
      annotatedDoc + trailingPathDoc
    }

    lazy val docsOpt: Option[Doc] = if (docs.isEmpty) None else Some(docs)

    lazy val examples: Map[String, Any] = annotations.foldLeft(Map.empty[String, Any]) {
      case (examples, HttpCodec.Metadata.Examples(nextExamples)) => examples ++ nextExamples
      case (examples, _)                                         => examples
    }

    def examples(schema: Schema[_]): Map[String, OpenAPI.ReferenceOr.Or[OpenAPI.Example]] =
      examples.map { case (k, v) =>
        k -> OpenAPI.ReferenceOr.Or(OpenAPI.Example(toJsonAst(schema, v)))
      }

    def name: Option[String] =
      codec match {
        case value: SegmentCodec[_] =>
          value match {
            case SegmentCodec.BoolSeg(name) => Some(name)
            case SegmentCodec.IntSeg(name)  => Some(name)
            case SegmentCodec.LongSeg(name) => Some(name)
            case SegmentCodec.Text(name)    => Some(name)
            case SegmentCodec.UUID(name)    => Some(name)
            case SegmentCodec.Trailing      => Some(PathWildcard)
            case _                          => None
          }
        case _                      =>
          findName(annotations)
      }

    def required: Boolean =
      !annotations.exists(_.isInstanceOf[HttpCodec.Metadata.Optional[_]])

    def deprecated: Boolean =
      annotations.exists(_.isInstanceOf[HttpCodec.Metadata.Deprecated[_]])
  }
  final case class AtomizedMetaCodecs(
    method: Chunk[MetaCodec[SimpleCodec[Method, _]]],
    path: Chunk[MetaCodec[SegmentCodec[_]]],
    query: Chunk[MetaCodec[HttpCodec.Query[_, _]]],
    header: Chunk[MetaCodec[HttpCodec.Header[_]]],
    content: Chunk[MetaCodec[HttpCodec.Atom[HttpCodecType.Content, _]]],
    status: Chunk[MetaCodec[HttpCodec.Status[_]]],
  ) {
    def append(metaCodec: MetaCodec[_]): AtomizedMetaCodecs = metaCodec match {
      case MetaCodec(codec: HttpCodec.Method[_], annotations) =>
        copy(method =
          (method :+ MetaCodec(codec.codec, annotations)).asInstanceOf[Chunk[MetaCodec[SimpleCodec[Method, _]]]],
        )
      case MetaCodec(_: SegmentCodec[_], _)                   =>
        copy(path = path :+ metaCodec.asInstanceOf[MetaCodec[SegmentCodec[_]]])
      case MetaCodec(_: HttpCodec.Query[_, _], _)             =>
        copy(query = query :+ metaCodec.asInstanceOf[MetaCodec[HttpCodec.Query[_, _]]])
      case MetaCodec(_: HttpCodec.Header[_], _)               =>
        copy(header = header :+ metaCodec.asInstanceOf[MetaCodec[HttpCodec.Header[_]]])
      case MetaCodec(_: HttpCodec.Status[_], _)               =>
        copy(status = status :+ metaCodec.asInstanceOf[MetaCodec[HttpCodec.Status[_]]])
      case MetaCodec(_: HttpCodec.Content[_], _)              =>
        copy(content = content :+ metaCodec.asInstanceOf[MetaCodec[HttpCodec.Atom[HttpCodecType.Content, _]]])
      case MetaCodec(_: HttpCodec.ContentStream[_], _)        =>
        copy(content = content :+ metaCodec.asInstanceOf[MetaCodec[HttpCodec.Atom[HttpCodecType.Content, _]]])
      case _                                                  => this
    }

    def ++(that: AtomizedMetaCodecs): AtomizedMetaCodecs =
      AtomizedMetaCodecs(
        method ++ that.method,
        path ++ that.path,
        query ++ that.query,
        header ++ that.header,
        content ++ that.content,
        status ++ that.status,
      )

    def contentExamples: Map[String, OpenAPI.ReferenceOr.Or[OpenAPI.Example]] =
      content.flatMap {
        case mc @ MetaCodec(HttpCodec.Content(codec, _, _), _) if codec.lookup(MediaType.application.json).isDefined =>
          mc.examples(codec.lookup(MediaType.application.json).get._2.schema)
        case mc @ MetaCodec(HttpCodec.ContentStream(codec, _, _), _)
            if codec.lookup(MediaType.application.json).isDefined =>
          mc.examples(codec.lookup(MediaType.application.json).get._2.schema)
        case _                                                                                                       =>
          Map.empty[String, OpenAPI.ReferenceOr.Or[OpenAPI.Example]]
      }.toMap

    // in case of alternatives,
    // the doc to the alternation is added to all sub elements of the alternatives.
    // This is not ideal. But it is the best we can do.
    // To get the doc that is only for the alternation, we take the intersection of all docs,
    // since only the alternation doc is added to all sub elements.
    def contentDocs: Doc =
      content
        .flatMap(_.docsOpt)
        .map(_.flattened)
        .reduceOption(_ intersect _)
        .flatMap(_.reduceOption(_ + _))
        .getOrElse(Doc.empty)

    def optimize: AtomizedMetaCodecs =
      AtomizedMetaCodecs(
        method.materialize,
        path.materialize,
        query.materialize,
        header.materialize,
        content.materialize,
        status.materialize,
      )
  }

  object AtomizedMetaCodecs {
    def empty: AtomizedMetaCodecs = AtomizedMetaCodecs(
      method = Chunk.empty,
      path = Chunk.empty,
      query = Chunk.empty,
      header = Chunk.empty,
      content = Chunk.empty,
      status = Chunk.empty,
    )

    def flatten[R, A](codec: HttpCodec[R, A]): AtomizedMetaCodecs = {
      val atoms = flattenedAtoms(codec)

      val flattened = atoms
        .foldLeft(AtomizedMetaCodecs.empty) { case (acc, atom) =>
          acc.append(atom)
        }
        .optimize
      flattened
    }

    private def flattenedAtoms[R, A](
      in: HttpCodec[R, A],
      annotations: Chunk[HttpCodec.Metadata[Any]] = Chunk.empty,
    ): Chunk[MetaCodec[_]] =
      in match {
        case codec @ HttpCodec.Combine(left, right, combiner) =>
          flattenedAtoms(
            left,
            HttpCodec
              .reduceExamplesLeft[A, codec.Left, codec.Right](
                annotations.asInstanceOf[Chunk[HttpCodec.Metadata[A]]],
                combiner,
              )
              .asInstanceOf[Chunk[HttpCodec.Metadata[Any]]],
          ) ++
            flattenedAtoms(
              right,
              HttpCodec
                .reduceExamplesRight[A, codec.Left, codec.Right](
                  annotations.asInstanceOf[Chunk[HttpCodec.Metadata[A]]],
                  combiner,
                )
                .asInstanceOf[Chunk[HttpCodec.Metadata[Any]]],
            )
        case path: HttpCodec.Path[_]                          => metaCodecFromPathCodec(path.pathCodec, annotations)
        case atom: HttpCodec.Atom[_, A]                       => Chunk(MetaCodec(atom, annotations))
        case map: HttpCodec.TransformOrFail[_, _, _]          =>
          flattenedAtoms(map.api, annotations.map(_.transformOrFail(map.g.asInstanceOf[Any => Either[String, Any]])))
        case HttpCodec.Empty                                  => Chunk.empty
        case HttpCodec.Halt                                   => Chunk.empty
        case _: HttpCodec.Fallback[_, _, _]       => in.alternatives.map(_._1).flatMap(flattenedAtoms(_, annotations))
        case HttpCodec.Annotated(api, annotation) =>
          flattenedAtoms(api, annotations :+ annotation.asInstanceOf[HttpCodec.Metadata[Any]])
      }
  }

  def method(in: Chunk[MetaCodec[SimpleCodec[Method, _]]]): Method = {
    if (in.size > 1) throw new Exception("Multiple methods not supported")
    in.collectFirst { case MetaCodec(SimpleCodec.Specified(method: Method), _) => method }
      .getOrElse(throw new Exception("No method specified"))
  }

  def metaCodecFromPathCodec(
    codec: PathCodec[_],
    annotations: Chunk[HttpCodec.Metadata[_]],
  ): Chunk[MetaCodec[SegmentCodec[_]]] = {
    def loop(
      path: PathCodec[_],
      annotations: Chunk[HttpCodec.Metadata[_]],
    ): Chunk[(SegmentCodec[_], Chunk[HttpCodec.Metadata[_]])] = path match {
      case PathCodec.Annotated(codec, newAnnotations) =>
        loop(codec, newAnnotations.map(toHttpCodecAnnotations) ++ annotations)
      case PathCodec.Segment(segment)                 => Chunk(segment -> annotations)

      case PathCodec.Concat(left, right, combiner) =>
        loop(left, HttpCodec.reduceExamplesLeft(annotations.asInstanceOf[Chunk[HttpCodec.Metadata[Any]]], combiner)) ++
          loop(right, HttpCodec.reduceExamplesRight(annotations.asInstanceOf[Chunk[HttpCodec.Metadata[Any]]], combiner))

      case codec @ PathCodec.TransformOrFail(api, _, g) =>
        loop(
          api,
          annotations.map(_.transform { v =>
            g(v.asInstanceOf[codec.Out]) match {
              case Left(error)  => throw new Exception(error)
              case Right(value) => value
            }
          }),
        )
      case PathCodec.Fallback(left, _)                  =>
        loop(left, annotations)
    }

    loop(codec, annotations).map { case (sc, annotations) =>
      MetaCodec(sc.asInstanceOf[SegmentCodec[_]], annotations.asInstanceOf[Chunk[HttpCodec.Metadata[Any]]])
    }.asInstanceOf[Chunk[MetaCodec[SegmentCodec[_]]]]
  }

  def toHttpCodecAnnotations(annotation: PathCodec.MetaData[_]): HttpCodec.Metadata[_] =
    annotation match {
      case PathCodec.MetaData.Documented(value)  => HttpCodec.Metadata.Documented(value)
      case PathCodec.MetaData.Examples(examples) => HttpCodec.Metadata.Examples(examples)
    }

  def contentAsJsonSchema[R, A](
    codec: HttpCodec[R, A],
    metadata: Chunk[HttpCodec.Metadata[_]] = Chunk.empty,
    referenceType: SchemaStyle = SchemaStyle.Inline,
    wrapInObject: Boolean = false,
    omitDescription: Boolean = false,
  )(mediaType: MediaType): JsonSchema = {
    val descriptionFromMeta = if (omitDescription) None else description(metadata)
    codec match {
      case atom: HttpCodec.Atom[_, _]                              =>
        atom match {
          case HttpCodec.Content(codec, maybeName, _) if wrapInObject       =>
            val name =
              findName(metadata).orElse(maybeName).getOrElse(throw new Exception("Multipart content without name"))
            JsonSchema.obj(
              name -> JsonSchema
                .fromZSchema(codec.lookup(mediaType).map(_._2.schema).getOrElse(codec.defaultSchema), referenceType)
                .description(descriptionFromMeta)
                .deprecated(deprecated(metadata))
                .nullable(optional(metadata)),
            )
          case HttpCodec.ContentStream(codec, maybeName, _)
              if wrapInObject && codec
                .lookup(mediaType)
                .map(_._2.schema)
                .getOrElse(codec.defaultSchema) == Schema[Byte] =>
            val name =
              findName(metadata).orElse(maybeName).getOrElse(throw new Exception("Multipart content without name"))
            JsonSchema.obj(
              name -> JsonSchema
                .fromZSchema(codec.lookup(mediaType).map(_._2.schema).getOrElse(codec.defaultSchema), referenceType)
                .description(descriptionFromMeta)
                .deprecated(deprecated(metadata))
                .nullable(optional(metadata))
                // currently we have no information about the encoding. So we just assume binary
                .contentEncoding(JsonSchema.ContentEncoding.Binary)
                .contentMediaType(MediaType.application.`octet-stream`.fullType),
            )
          case HttpCodec.ContentStream(codec, maybeName, _) if wrapInObject =>
            val name =
              findName(metadata).orElse(maybeName).getOrElse(throw new Exception("Multipart content without name"))
            JsonSchema.obj(
              name -> JsonSchema
                .fromZSchema(codec.lookup(mediaType).map(_._2.schema).getOrElse(codec.defaultSchema), referenceType)
                .description(descriptionFromMeta)
                .deprecated(deprecated(metadata))
                .nullable(optional(metadata)),
            )
          case HttpCodec.Content(codec, _, _)                               =>
            JsonSchema
              .fromZSchema(codec.lookup(mediaType).map(_._2.schema).getOrElse(codec.defaultSchema), referenceType)
              .description(descriptionFromMeta)
              .deprecated(deprecated(metadata))
              .nullable(optional(metadata))
          case HttpCodec.ContentStream(codec, _, _)                         =>
            JsonSchema
              .fromZSchema(codec.lookup(mediaType).map(_._2.schema).getOrElse(codec.defaultSchema), referenceType)
              .description(descriptionFromMeta)
              .deprecated(deprecated(metadata))
              .nullable(optional(metadata))
          case _                                                            => JsonSchema.Null
        }
      case HttpCodec.Annotated(codec, data)                        =>
        contentAsJsonSchema(codec, metadata :+ data, referenceType, wrapInObject, omitDescription)(mediaType)
      case HttpCodec.TransformOrFail(api, _, _)                    =>
        contentAsJsonSchema(api, metadata, referenceType, wrapInObject)(mediaType)
      case HttpCodec.Empty                                         => JsonSchema.Null
      case HttpCodec.Halt                                          => JsonSchema.Null
      case HttpCodec.Combine(left, right, _) if isMultipart(codec) =>
        (
          contentAsJsonSchema(left, Chunk.empty, referenceType, wrapInObject = true)(mediaType),
          contentAsJsonSchema(right, Chunk.empty, referenceType, wrapInObject = true)(mediaType),
        ) match {
          case (left, right) =>
            val annotations = left.annotations ++ right.annotations
            (left.withoutAnnotations, right.withoutAnnotations) match {
              case (JsonSchema.Object(p1, _, r1), JsonSchema.Object(p2, _, r2)) =>
                // seems odd to allow additional properties for multipart. So just hardcode it to false
                JsonSchema
                  .Object(p1 ++ p2, Left(false), r1 ++ r2)
                  .deprecated(deprecated(metadata))
                  .nullable(optional(metadata))
                  .description(descriptionFromMeta)
                  .annotate(annotations)
              case (JsonSchema.Object(p, _, r), JsonSchema.Null)                =>
                JsonSchema
                  .Object(p, Left(false), r)
                  .deprecated(deprecated(metadata))
                  .nullable(optional(metadata))
                  .description(descriptionFromMeta)
                  .annotate(annotations)
              case (JsonSchema.Null, JsonSchema.Object(p, _, r))                =>
                JsonSchema
                  .Object(p, Left(false), r)
                  .deprecated(deprecated(metadata))
                  .nullable(optional(metadata))
                  .description(descriptionFromMeta)
                  .annotate(annotations)
              case _ => throw new IllegalArgumentException("Multipart content without name.")
            }

        }
      case HttpCodec.Combine(left, right, _)                       =>
        (
          contentAsJsonSchema(left, Chunk.empty, referenceType, wrapInObject, omitDescription)(mediaType),
          contentAsJsonSchema(right, Chunk.empty, referenceType, wrapInObject, omitDescription)(mediaType),
        ) match {
          case (JsonSchema.Null, JsonSchema.Null) =>
            JsonSchema.Null
          case (JsonSchema.Null, schema)          =>
            schema
              .deprecated(deprecated(metadata))
              .nullable(optional(metadata))
              .description(descriptionFromMeta)
          case (schema, JsonSchema.Null)          =>
            schema
              .deprecated(deprecated(metadata))
              .nullable(optional(metadata))
              .description(descriptionFromMeta)
          case _                                  =>
            throw new IllegalStateException("A non multipart combine, should lead to at least one null schema.")
        }
      case HttpCodec.Fallback(_, _, _, _) => throw new IllegalArgumentException("Fallback not supported at this point")
    }
  }

  private def findName(metadata: Chunk[HttpCodec.Metadata[_]]): Option[String] =
    metadata.reverse
      .find(_.isInstanceOf[Metadata.Named[_]])
      .asInstanceOf[Option[Metadata.Named[Any]]]
      .map(_.name)

  private def description(metadata: Chunk[HttpCodec.Metadata[_]]): Option[String] =
    metadata.collect { case HttpCodec.Metadata.Documented(doc) => doc }
      .reduceOption(_ + _)
      .map(_.toCommonMark)

  private def deprecated(metadata: Chunk[HttpCodec.Metadata[_]]): Boolean =
    metadata.exists(_.isInstanceOf[HttpCodec.Metadata.Deprecated[_]])

  private def optional(metadata: Chunk[HttpCodec.Metadata[_]]): Boolean =
    metadata.exists(_.isInstanceOf[HttpCodec.Metadata.Optional[_]])

  def status[R, A](codec: HttpCodec[R, A]): Option[Status] =
    codec match {
      case HttpCodec.Status(simpleCodec, _) if simpleCodec.isInstanceOf[SimpleCodec.Specified[_]] =>
        Some(simpleCodec.asInstanceOf[SimpleCodec.Specified[Status]].value)
      case HttpCodec.Annotated(codec, _)                                                          =>
        status(codec)
      case HttpCodec.TransformOrFail(api, _, _)                                                   =>
        status(api)
      case HttpCodec.Empty                                                                        =>
        None
      case HttpCodec.Halt                                                                         =>
        None
      case HttpCodec.Combine(left, right, _)                                                      =>
        status(left).orElse(status(right))
      case HttpCodec.Fallback(left, right, _, _)                                                  =>
        status(left).orElse(status(right))
      case _                                                                                      =>
        None
    }

  def isMultipart[R, A](codec: HttpCodec[R, A]): Boolean =
    codec match {
      case HttpCodec.Combine(left, right, _)      =>
        (isContent(left) && isContent(right)) ||
        isMultipart(left) || isMultipart(right)
      case HttpCodec.Annotated(codec, _)          => isMultipart(codec)
      case HttpCodec.TransformOrFail(codec, _, _) => isMultipart(codec)
      case _                                      => false
    }

  def isContent(value: HttpCodec[_, _]): Boolean =
    value match {
      case HttpCodec.Content(_, _, _)             => true
      case HttpCodec.ContentStream(_, _, _)       => true
      case HttpCodec.Annotated(codec, _)          => isContent(codec)
      case HttpCodec.TransformOrFail(codec, _, _) => isContent(codec)
      case HttpCodec.Combine(left, right, _)      => isContent(left) || isContent(right)
      case _                                      => false
    }

  private def toJsonAst(schema: Schema[_], v: Any): Json =
    JsonCodec
      .jsonEncoder(schema.asInstanceOf[Schema[Any]])
      .toJsonAST(v)
      .toOption
      .get

  def fromEndpoints(
    endpoint1: Endpoint[_, _, _, _, _],
    endpoints: Endpoint[_, _, _, _, _]*,
  ): OpenAPI = fromEndpoints(endpoint1 +: endpoints)

  def fromEndpoints(
    title: String,
    version: String,
    endpoint1: Endpoint[_, _, _, _, _],
    endpoints: Endpoint[_, _, _, _, _]*,
  ): OpenAPI = fromEndpoints(title, version, endpoint1 +: endpoints)

  def fromEndpoints(
    title: String,
    version: String,
    referenceType: SchemaStyle,
    endpoint1: Endpoint[_, _, _, _, _],
    endpoints: Endpoint[_, _, _, _, _]*,
  ): OpenAPI = fromEndpoints(title, version, referenceType, endpoint1 +: endpoints)

  def fromEndpoints(
    referenceType: SchemaStyle,
    endpoints: Iterable[Endpoint[_, _, _, _, _]],
  ): OpenAPI = if (endpoints.isEmpty) OpenAPI.empty else endpoints.map(gen(_, referenceType)).reduce(_ ++ _)

  def fromEndpoints(
    endpoints: Iterable[Endpoint[_, _, _, _, _]],
  ): OpenAPI = if (endpoints.isEmpty) OpenAPI.empty else endpoints.map(gen(_, SchemaStyle.Compact)).reduce(_ ++ _)

  def fromEndpoints(
    title: String,
    version: String,
    endpoints: Iterable[Endpoint[_, _, _, _, _]],
  ): OpenAPI = fromEndpoints(endpoints).title(title).version(version)

  def fromEndpoints(
    title: String,
    version: String,
    referenceType: SchemaStyle,
    endpoints: Iterable[Endpoint[_, _, _, _, _]],
  ): OpenAPI = fromEndpoints(referenceType, endpoints).title(title).version(version)

  def gen(
    endpoint: Endpoint[_, _, _, _, _],
    referenceType: SchemaStyle = SchemaStyle.Compact,
  ): OpenAPI = {
    val inAtoms = AtomizedMetaCodecs.flatten(endpoint.input)
    val outs: Map[OpenAPI.StatusOrDefault, Map[MediaType, (JsonSchema, AtomizedMetaCodecs)]] =
      schemaByStatusAndMediaType(
        endpoint.output.alternatives.map(_._1) ++ endpoint.error.alternatives.map(_._1),
        referenceType,
        omitContentDescription = true,
      )
    // there is no status for inputs. So we just take the first one (default)
    val ins = schemaByStatusAndMediaType(endpoint.input.alternatives.map(_._1), referenceType).values.headOption

    def path: Map[Path, PathItem] = {
      val path           = buildPath(endpoint.input)
      val method0        = method(inAtoms.method)
      // Endpoint has only one doc. But open api has a summery and a description
      val pathItem       = OpenAPI.PathItem.empty
        .copy(description = Some(endpoint.documentation + endpoint.input.doc.getOrElse(Doc.empty)).filter(!_.isEmpty))
      val pathItemWithOp = method0 match {
        case Method.OPTIONS => pathItem.addOptions(operation(endpoint))
        case Method.GET     => pathItem.addGet(operation(endpoint))
        case Method.HEAD    => pathItem.addHead(operation(endpoint))
        case Method.POST    => pathItem.addPost(operation(endpoint))
        case Method.PUT     => pathItem.addPut(operation(endpoint))
        case Method.PATCH   => pathItem.addPatch(operation(endpoint))
        case Method.DELETE  => pathItem.addDelete(operation(endpoint))
        case Method.TRACE   => pathItem.addTrace(operation(endpoint))
        case Method.ANY     => pathItem.any(operation(endpoint))
        case method         => throw new IllegalArgumentException(s"OpenAPI does not support method $method")
      }
      Map(path -> pathItemWithOp)
    }

    def buildPath(in: HttpCodec[_, _]): OpenAPI.Path = {

      def pathCodec(in1: HttpCodec[_, _]): Option[HttpCodec.Path[_]] = in1 match {
        case atom: HttpCodec.Atom[_, _]            =>
          atom match {
            case codec @ HttpCodec.Path(_, _) => Some(codec)
            case _                            => None
          }
        case HttpCodec.Annotated(in, _)            => pathCodec(in)
        case HttpCodec.TransformOrFail(api, _, _)  => pathCodec(api)
        case HttpCodec.Empty                       => None
        case HttpCodec.Halt                        => None
        case HttpCodec.Combine(left, right, _)     => pathCodec(left).orElse(pathCodec(right))
        case HttpCodec.Fallback(left, right, _, _) => pathCodec(left).orElse(pathCodec(right))
      }

      val pathString = {
        val codec = pathCodec(in).getOrElse(throw new Exception("No path found.")).pathCodec
        if (codec.render.endsWith(SegmentCodec.Trailing.render))
          codec.renderIgnoreTrailing + s"{$PathWildcard}"
        else codec.render
      }
      OpenAPI.Path.fromString(pathString).getOrElse(throw new Exception(s"Invalid path: $pathString"))
    }

    def operation(endpoint: Endpoint[_, _, _, _, _]): OpenAPI.Operation = {
      val maybeDoc = Some(endpoint.documentation + pathDoc).filter(!_.isEmpty)
      OpenAPI.Operation(
        tags = endpoint.tags,
        summary = None,
        description = maybeDoc,
        externalDocs = None,
        operationId = None,
        parameters = parameters,
        requestBody = requestBody,
        responses = responses,
        callbacks = Map.empty,
        security = Nil,
        servers = Nil,
      )
    }

    def pathDoc: Doc =
      inAtoms.path
        .flatMap(_.docsOpt)
        .map(_.flattened)
        .reduceOption(_ intersect _)
        .flatMap(_.reduceOption(_ + _))
        .getOrElse(Doc.empty)

    def requestBody: Option[OpenAPI.ReferenceOr[OpenAPI.RequestBody]] =
      ins.map { mediaTypes =>
        val combinedAtomizedCodecs = mediaTypes.map { case (_, (_, atomized)) => atomized }.reduce(_ ++ _)
        val mediaTypeResponses     = mediaTypes.map { case (mediaType, (schema, atomized)) =>
          mediaType.fullType -> OpenAPI.MediaType(
            schema = OpenAPI.ReferenceOr.Or(schema),
            examples = atomized.contentExamples,
            encoding = Map.empty,
          )
        }
        OpenAPI.ReferenceOr.Or(
          OpenAPI.RequestBody(
            content = mediaTypeResponses,
            required = combinedAtomizedCodecs.content.exists(_.required),
          ),
        )
      }.filter(_.value.content.exists {
        case (_, OpenAPI.MediaType(OpenAPI.ReferenceOr.Or(schema), _, _)) =>
          schema.withoutAnnotations != JsonSchema.Null
        case _                                                            => true
      })

    def responses: OpenAPI.Responses =
      responsesForAlternatives(outs)

    def parameters: Set[OpenAPI.ReferenceOr[OpenAPI.Parameter]] =
      queryParams ++ pathParams ++ headerParams

    def queryParams: Set[OpenAPI.ReferenceOr[OpenAPI.Parameter]] = {
      inAtoms.query.collect {
        case mc @ MetaCodec(HttpCodec.Query(HttpCodec.Query.QueryType.Primitive(name, codec), _), _)  =>
          OpenAPI.ReferenceOr.Or(
            OpenAPI.Parameter.queryParameter(
              name = name,
              description = mc.docsOpt,
              schema = Some(OpenAPI.ReferenceOr.Or(JsonSchema.fromZSchema(codec.schema))),
              deprecated = mc.deprecated,
              style = OpenAPI.Parameter.Style.Form,
              explode = false,
              allowReserved = false,
              examples = mc.examples.map { case (name, value) =>
                name -> OpenAPI.ReferenceOr.Or(OpenAPI.Example(value = Json.Str(value.toString)))
              },
              required = mc.required,
            ),
          ) :: Nil
        case mc @ MetaCodec(HttpCodec.Query(record @ HttpCodec.Query.QueryType.Record(schema), _), _) =>
          val recordSchema = (schema match {
            case schema if schema.isInstanceOf[Schema.Optional[_]] => schema.asInstanceOf[Schema.Optional[_]].schema
            case _                                                 => schema
          }).asInstanceOf[Schema.Record[Any]]
          val examples     = mc.examples.map { case (exName, ex) =>
            exName -> recordSchema.deconstruct(ex)(Unsafe.unsafe)
          }
          record.fieldAndCodecs.zipWithIndex.map { case ((field, codec), index) =>
            OpenAPI.ReferenceOr.Or(
              OpenAPI.Parameter.queryParameter(
                name = field.name,
                description = mc.docsOpt,
                schema = Some(OpenAPI.ReferenceOr.Or(JsonSchema.fromZSchema(codec.schema))),
                deprecated = mc.deprecated,
                style = OpenAPI.Parameter.Style.Form,
                explode = false,
                allowReserved = false,
                examples = examples.map { case (exName, values) =>
                  val fieldValue = values(index)
                    .orElse(field.defaultValue)
                    .getOrElse(
                      throw new Exception(s"No value or default value found for field ${exName}_${field.name}"),
                    )
                  s"${exName}_${field.name}" -> OpenAPI.ReferenceOr.Or(
                    OpenAPI.Example(value =
                      Json.Str(codec.codec(CodecConfig.defaultConfig).encode(fieldValue).asString),
                    ),
                  )
                },
                required = mc.required,
              ),
            )

          }
        case mc @ MetaCodec(
              HttpCodec.Query(
                HttpCodec.Query.QueryType.Collection(
                  _,
                  HttpCodec.Query.QueryType.Primitive(name, codec),
                  optional,
                ),
                _,
              ),
              _,
            ) =>
          OpenAPI.ReferenceOr.Or(
            OpenAPI.Parameter.queryParameter(
              name = name,
              description = mc.docsOpt,
              schema = Some(OpenAPI.ReferenceOr.Or(JsonSchema.fromZSchema(codec.schema))),
              deprecated = mc.deprecated,
              style = OpenAPI.Parameter.Style.Form,
              explode = false,
              allowReserved = false,
              examples = mc.examples.map { case (exName, value) =>
                exName -> OpenAPI.ReferenceOr.Or(OpenAPI.Example(value = Json.Str(value.toString)))
              },
              required = !optional,
            ),
          ) :: Nil
      }
    }.flatten.toSet

    def pathParams: Set[OpenAPI.ReferenceOr[OpenAPI.Parameter]] =
      inAtoms.path.collect {
        case mc @ MetaCodec(codec, _) if codec != SegmentCodec.Empty && !codec.isInstanceOf[SegmentCodec.Literal] =>
          OpenAPI.ReferenceOr.Or(
            OpenAPI.Parameter.pathParameter(
              name = mc.name.getOrElse(throw new Exception("Path parameter must have a name")),
              description = mc.docsOpt.flatMap(_.flattened.filterNot(_ == pathDoc).reduceOption(_ + _)),
              definition = Some(OpenAPI.ReferenceOr.Or(JsonSchema.fromSegmentCodec(codec))),
              deprecated = mc.deprecated,
              style = OpenAPI.Parameter.Style.Simple,
              examples = mc.examples.map { case (name, value) =>
                name -> OpenAPI.ReferenceOr.Or(OpenAPI.Example(segmentToJson(codec, value)))
              },
            ),
          )
      }.toSet

    def headerParams: Set[OpenAPI.ReferenceOr[OpenAPI.Parameter]] =
      inAtoms.header
        .asInstanceOf[Chunk[MetaCodec[HttpCodec.Header[Any]]]]
        .map { case mc @ MetaCodec(codec, _) =>
          OpenAPI.ReferenceOr.Or(
            OpenAPI.Parameter.headerParameter(
              name = mc.name.getOrElse(codec.name),
              description = mc.docsOpt,
              definition = Some(OpenAPI.ReferenceOr.Or(JsonSchema.fromTextCodec(codec.textCodec))),
              deprecated = mc.deprecated,
              examples = mc.examples.map { case (name, value) =>
                name -> OpenAPI.ReferenceOr.Or(OpenAPI.Example(codec.textCodec.encode(value).toJsonAST.toOption.get))
              },
              required = mc.required,
            ),
          )
        }
        .toSet

    def genDiscriminator(schema: Schema[_]): Option[OpenAPI.Discriminator] = {
      schema match {
        case enumSchema: Schema.Enum[_] =>
          val discriminatorName =
            enumSchema.annotations.collectFirst { case zio.schema.annotation.discriminatorName(name) => name }
          val noDiscriminator   = enumSchema.annotations.contains(zio.schema.annotation.noDiscriminator())
          val typeMapping       = enumSchema.cases.map { case_ =>
            val caseName =
              case_.annotations.collectFirst { case zio.schema.annotation.caseName(name) => name }.getOrElse(case_.id)
            // There should be no enums with cases that are not records with a nominal id
            // TODO: not true. Since one could build a schema with a enum with a case that is a primitive
            val typeId   =
              (case_.schema match {
                case lzy: Schema.Lazy[_] => lzy.schema
                case _                   => case_.schema
              })
                .asInstanceOf[Schema.Record[_]]
                .id
                .asInstanceOf[TypeId.Nominal]
            caseName -> schemaReferencePath(typeId, referenceType)
          }

          if (noDiscriminator) None
          else discriminatorName.map(name => OpenAPI.Discriminator(name, typeMapping.toMap))

        case _ => None
      }
    }

    def components = OpenAPI.Components(
      schemas = ListMap(componentSchemas.toSeq.sortBy(_._1.name): _*),
      responses = ListMap.empty,
      parameters = ListMap.empty,
      examples = ListMap.empty,
      requestBodies = ListMap.empty,
      headers = ListMap.empty,
      securitySchemes = ListMap.empty,
      links = ListMap.empty,
      callbacks = ListMap.empty,
    )

    def segmentToJson(codec: SegmentCodec[_], value: Any): Json = {
      codec match {
        case SegmentCodec.Empty             => throw new Exception("Empty segment not allowed")
        case SegmentCodec.Literal(_)        => throw new Exception("Literal segment not allowed")
        case SegmentCodec.BoolSeg(_)        => Json.Bool(value.asInstanceOf[Boolean])
        case SegmentCodec.IntSeg(_)         => Json.Num(value.asInstanceOf[Int])
        case SegmentCodec.LongSeg(_)        => Json.Num(value.asInstanceOf[Long])
        case SegmentCodec.Text(_)           => Json.Str(value.asInstanceOf[String])
        case SegmentCodec.UUID(_)           => Json.Str(value.asInstanceOf[UUID].toString)
        case SegmentCodec.Trailing          => throw new Exception("Trailing segment not allowed")
        case SegmentCodec.Combined(_, _, _) => throw new Exception("Combined segment not allowed")
      }
    }

    def jsonSchemaFromCodec(codec: HttpContentCodec[_]): Option[Schema[_]] =
      codec.lookup(MediaType.application.json).map(_._2.schema)

    def componentSchemas: Map[OpenAPI.Key, OpenAPI.ReferenceOr[JsonSchema]] =
      (endpoint.input.alternatives.map(_._1).map(AtomizedMetaCodecs.flatten(_)).flatMap(_.content)
        ++ endpoint.error.alternatives.map(_._1).map(AtomizedMetaCodecs.flatten(_)).flatMap(_.content)
        ++ endpoint.output.alternatives.map(_._1).map(AtomizedMetaCodecs.flatten(_)).flatMap(_.content)).collect {
        case MetaCodec(HttpCodec.Content(codec, _, _), _)
            if jsonSchemaFromCodec(codec).isDefined &&
              nominal(jsonSchemaFromCodec(codec).get, referenceType).isDefined =>
          val schemas = JsonSchema.fromZSchemaMulti(jsonSchemaFromCodec(codec).get, referenceType)
          schemas.children.map { case (key, schema) =>
            OpenAPI.Key.fromString(key.replace("#/components/schemas/", "")).get -> OpenAPI.ReferenceOr.Or(schema)
          } + (OpenAPI.Key.fromString(nominal(jsonSchemaFromCodec(codec).get, referenceType).get).get ->
            OpenAPI.ReferenceOr.Or(schemas.root.discriminator(genDiscriminator(jsonSchemaFromCodec(codec).get))))

        case MetaCodec(HttpCodec.Content(setCodec, _, _), _)
            if jsonSchemaFromCodec(setCodec).isDefined && jsonSchemaFromCodec(setCodec).get.isInstanceOf[Schema.Set[_]]
              && nominal(
                jsonSchemaFromCodec(setCodec).get.asInstanceOf[Schema.Set[_]].elementSchema,
                referenceType,
              ).isDefined =>
          val schema  = jsonSchemaFromCodec(setCodec).get.asInstanceOf[Schema.Set[_]].elementSchema
          val schemas = JsonSchema.fromZSchemaMulti(schema, referenceType)
          schemas.children.map { case (key, schema) =>
            OpenAPI.Key.fromString(key.replace("#/components/schemas/", "")).get -> OpenAPI.ReferenceOr.Or(schema)
          } + (OpenAPI.Key.fromString(nominal(schema, referenceType).get).get ->
            OpenAPI.ReferenceOr.Or(schemas.root.discriminator(genDiscriminator(schema))))

        case MetaCodec(HttpCodec.Content(seqCodec, _, _), _)
            if jsonSchemaFromCodec(seqCodec).isDefined && jsonSchemaFromCodec(seqCodec).get
              .isInstanceOf[Schema.Sequence[_, _, _]]
              && nominal(
                jsonSchemaFromCodec(seqCodec).get.asInstanceOf[Schema.Sequence[_, _, _]].elementSchema,
                referenceType,
              ).isDefined =>
          val schema  = jsonSchemaFromCodec(seqCodec).get.asInstanceOf[Schema.Sequence[_, _, _]].elementSchema
          val schemas = JsonSchema.fromZSchemaMulti(schema, referenceType)
          schemas.children.map { case (key, schema) =>
            OpenAPI.Key.fromString(key.replace("#/components/schemas/", "")).get -> OpenAPI.ReferenceOr.Or(schema)
          } + (OpenAPI.Key.fromString(nominal(schema, referenceType).get).get ->
            OpenAPI.ReferenceOr.Or(schemas.root.discriminator(genDiscriminator(schema))))

        case MetaCodec(HttpCodec.Content(mapCodec, _, _), _)
            if jsonSchemaFromCodec(mapCodec).isDefined && jsonSchemaFromCodec(mapCodec).get
              .isInstanceOf[Schema.Map[_, _]]
              && nominal(
                jsonSchemaFromCodec(mapCodec).get.asInstanceOf[Schema.Map[_, _]].valueSchema,
                referenceType,
              ).isDefined =>
          val schema  = jsonSchemaFromCodec(mapCodec).get.asInstanceOf[Schema.Map[_, _]].valueSchema
          val schemas = JsonSchema.fromZSchemaMulti(schema, referenceType)
          schemas.children.map { case (key, schema) =>
            OpenAPI.Key.fromString(key.replace("#/components/schemas/", "")).get -> OpenAPI.ReferenceOr.Or(schema)
          } + (OpenAPI.Key.fromString(nominal(schema, referenceType).get).get ->
            OpenAPI.ReferenceOr.Or(schemas.root.discriminator(genDiscriminator(schema))))

        case MetaCodec(HttpCodec.ContentStream(codec, _, _), _)
            if jsonSchemaFromCodec(codec).isDefined && nominal(
              jsonSchemaFromCodec(codec).get,
              referenceType,
            ).isDefined =>
          val schemas = JsonSchema.fromZSchemaMulti(jsonSchemaFromCodec(codec).get, referenceType)
          schemas.children.map { case (key, schema) =>
            OpenAPI.Key.fromString(key.replace("#/components/schemas/", "")).get -> OpenAPI.ReferenceOr.Or(schema)
          } + (OpenAPI.Key.fromString(nominal(jsonSchemaFromCodec(codec).get, referenceType).get).get ->
            OpenAPI.ReferenceOr.Or(schemas.root.discriminator(genDiscriminator(jsonSchemaFromCodec(codec).get))))
      }.flatten.toMap

    OpenAPI(
      "3.1.0",
      info = OpenAPI.Info(
        title = "",
        description = None,
        termsOfService = None,
        contact = None,
        license = None,
        version = "",
      ),
      servers = Nil,
      paths = ListMap(path.toSeq.sortBy(_._1.name): _*),
      components = Some(components),
      security = Nil,
      tags = Nil,
      externalDocs = None,
    )
  }

  private def schemaByStatusAndMediaType(
    alternatives: Chunk[HttpCodec[_, _]],
    referenceType: SchemaStyle,
    omitContentDescription: Boolean = false,
  ): Map[OpenAPI.StatusOrDefault, Map[MediaType, (JsonSchema, AtomizedMetaCodecs)]] = {
    val statusAndCodec =
      alternatives.map { codec =>
        val statusOrDefault =
          status(codec).map(OpenAPI.StatusOrDefault.StatusValue(_)).getOrElse(OpenAPI.StatusOrDefault.Default)
        (
          statusOrDefault,
          (
            AtomizedMetaCodecs.flatten(codec),
            contentAsJsonSchema(codec, referenceType = referenceType, omitDescription = omitContentDescription) _,
          ),
        )
      }

    groupMap(statusAndCodec) { case (status, _) => status } { case (_, atomizedAndSchema) =>
      atomizedAndSchema
    }.map { case (status, values) =>
      val mapped = values
        .foldLeft(Chunk.empty[(MediaType, (AtomizedMetaCodecs, JsonSchema))]) { case (acc, (atomized, schema)) =>
          if (atomized.content.size > 1) {
            acc :+ ((MediaType.multipart.`form-data`, (atomized, schema(MediaType.multipart.`form-data`))))
          } else {
            val mediaType = atomized.content.headOption match {
              case Some(MetaCodec(HttpCodec.Content(codec, _, _), _))       =>
                codec.defaultMediaType
              case Some(MetaCodec(HttpCodec.ContentStream(codec, _, _), _)) =>
                if (codec.defaultSchema == Schema[Byte]) MediaType.application.`octet-stream`
                else codec.defaultMediaType
              case _                                                        =>
                MediaType.application.`json`
            }
            acc :+ ((mediaType, (atomized, schema(mediaType))))
          }
        }
      status -> groupMap(mapped) { case (mediaType, _) => mediaType } { case (_, atomizedAndSchema) =>
        atomizedAndSchema
      }.map {
        case (mediaType, Chunk((atomized, schema))) if values.size == 1 =>
          (mediaType, (schema, atomized))
        case (mediaType, values)                                        =>
          val combinedAtomized: AtomizedMetaCodecs = values.map(_._1).reduce(_ ++ _)
          val combinedContentDoc                   = combinedAtomized.contentDocs.toCommonMark
          val alternativesSchema                   = {
            JsonSchema
              .AnyOfSchema(values.map { case (_, schema) =>
                schema.description match {
                  case Some(value) => schema.description(value.replace(combinedContentDoc, ""))
                  case None        => schema
                }
              })
              .minify
              .description(combinedContentDoc)
          }
          (mediaType, (alternativesSchema, combinedAtomized))
      }
    }
  }

  @tailrec
  def nominal(schema: Schema[_], referenceType: SchemaStyle): Option[String] =
    schema match {
      case enumSchema: Schema.Enum[_] =>
        enumSchema.id match {
          case TypeId.Structural                                               =>
            None
          case nominal: TypeId.Nominal if referenceType == SchemaStyle.Compact =>
            Some(nominal.typeName)
          case nominal: TypeId.Nominal                                         =>
            Some(nominal.fullyQualified.replace(".", "_"))
        }
      case record: Record[_]          =>
        record.id match {
          case TypeId.Structural                                               =>
            None
          case nominal: TypeId.Nominal if referenceType == SchemaStyle.Compact =>
            Some(nominal.typeName)
          case nominal: TypeId.Nominal                                         =>
            Some(nominal.fullyQualified.replace(".", "_"))
        }
      case t: Transform[_, _, _]      =>
        nominal(t.schema, referenceType)
      case _                          => None
    }

  private def responsesForAlternatives(
    codecs: Map[OpenAPI.StatusOrDefault, Map[MediaType, (JsonSchema, AtomizedMetaCodecs)]],
  ): Map[OpenAPI.StatusOrDefault, OpenAPI.ReferenceOr[OpenAPI.Response]] =
    codecs.map { case (status, mediaTypes) =>
      val combinedAtomizedCodecs = mediaTypes.map { case (_, (_, atomized)) => atomized }.reduce(_ ++ _)
      val mediaTypeResponses     = mediaTypes.map { case (mediaType, (schema, atomized)) =>
        mediaType.fullType -> OpenAPI.MediaType(
          schema = OpenAPI.ReferenceOr.Or(schema),
          examples = atomized.contentExamples,
          encoding = Map.empty,
        )
      }
      status -> OpenAPI.ReferenceOr.Or(
        OpenAPI.Response(
          description = combinedAtomizedCodecs.status.headOption.flatMap(_.docsOpt),
          headers = headersFrom(combinedAtomizedCodecs),
          content = mediaTypeResponses,
          links = Map.empty,
        ),
      )
    }

  private def headersFrom(codec: AtomizedMetaCodecs)                                           = {
    codec.header.map { case mc @ MetaCodec(codec, _) =>
      codec.name -> OpenAPI.ReferenceOr.Or(
        OpenAPI.Header(
          description = mc.docsOpt,
          required = true,
          deprecated = mc.deprecated,
          allowEmptyValue = false,
          schema = Some(JsonSchema.fromTextCodec(codec.textCodec)),
        ),
      )
    }.toMap
  }
  private def schemaReferencePath(nominal: TypeId.Nominal, referenceType: SchemaStyle): String = {
    referenceType match {
      case SchemaStyle.Compact => s"#/components/schemas/${nominal.typeName}"
      case _                   => s"#/components/schemas/${nominal.fullyQualified.replace(".", "_")}}"
    }
  }
}




© 2015 - 2024 Weber Informatics LLC | Privacy Policy