Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Generate Mime types #2918

Merged
merged 6 commits into from
Jun 19, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ jobs:
run: sbt '++ ${{ matrix.scala }}' zioHttpShadedTests/test

- name: Compress target directories
run: tar cf targets.tar sbt-zio-http-grpc/target zio-http-cli/target target zio-http/jvm/target zio-http-docs/target sbt-zio-http-grpc-tests/target zio-http-gen/target zio-http-benchmarks/target zio-http-example/target zio-http-testkit/target zio-http/js/target zio-http-htmx/target project/target
run: tar cf targets.tar sbt-zio-http-grpc/target zio-http-cli/target target zio-http/jvm/target zio-http-docs/target sbt-zio-http-grpc-tests/target zio-http-gen/target zio-http-benchmarks/target zio-http-tools/target zio-http-example/target zio-http-testkit/target zio-http/js/target zio-http-htmx/target project/target

- name: Upload target directories
uses: actions/upload-artifact@v4
Expand Down
1 change: 1 addition & 0 deletions aliases.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ addCommandAlias("fmt", "scalafmt; Test / scalafmt; sFix;")
addCommandAlias("fmtCheck", "scalafmtCheck; Test / scalafmtCheck; sFixCheck")
addCommandAlias("sFix", "scalafix OrganizeImports; Test / scalafix OrganizeImports")
addCommandAlias("sFixCheck", "scalafix --check OrganizeImports; Test / scalafix --check OrganizeImports")
addCommandAlias("generateMediaTypes", "zioHttpTools/runMain zio.http.tools.GenerateMediaTypes")

onLoadMessage := {
import scala.Console._
Expand Down
7 changes: 7 additions & 0 deletions build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ lazy val aggregatedProjects: Seq[ProjectReference] =
zioHttpHtmx,
zioHttpExample,
zioHttpTestkit,
zioHttpTools,
docs,
)
}
Expand Down Expand Up @@ -290,6 +291,12 @@ lazy val zioHttpExample = (project in file("zio-http-example"))
)
.dependsOn(zioHttpJVM, zioHttpCli, zioHttpGen)

lazy val zioHttpTools = (project in file("zio-http-tools"))
.settings(stdSettings("zio-http-tools"))
.settings(publishSetting(false))
.settings(runSettings(Debug.Main))
.dependsOn(zioHttpJVM)

lazy val zioHttpGen = (project in file("zio-http-gen"))
.settings(stdSettings("zio-http-gen"))
.settings(publishSetting(true))
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
package zio.http.tools

import scala.io.Source

import zio._
import zio.json._

/////////////////////////
// READING AND PARSING //
/////////////////////////

case class MimeType(
source: Option[String],
extensions: Option[List[String]] = None,
compressible: Option[Boolean] = None,
charset: Option[String] = None,
)

object MimeType {
implicit val decoder: JsonDecoder[MimeType] = DeriveJsonDecoder.gen[MimeType]
}

case class MimeDb(mimeTypes: Map[String, MimeType]) extends AnyVal {
def extend(extraTypes: Map[String, MimeType]): MimeDb = {
MimeDb(mimeTypes ++ extraTypes)
}
}

object MimeDb {
implicit val decoder: JsonDecoder[MimeDb] = JsonDecoder.map[String, MimeType].map(MimeDb(_))

// These types are not in the mime-db json
val extraTypes = Map(
"text/event-stream" -> MimeType(None, None, Some(true), None),
)

// Fetches the MIME types database from the jshttp/mime-db repository and
// returns a MimeDb object
def fetch: Task[MimeDb] = ZIO.attemptBlocking {
val url = "https://raw.githubusercontent.com/jshttp/mime-db/master/db.json"

val source = Source.fromURL(url)
val jsonData =
try { source.mkString }
finally { source.close() }

jsonData.fromJson[MimeDb] match {
case Right(db) => db.extend(extraTypes)
case Left(error) => throw new RuntimeException(s"Failed to parse JSON: $error")
}
}
}

///////////////
// RENDERING //
///////////////

object RenderUtils {
def snakeCase(s: String): String = {
s.toLowerCase.replace("-", "_")
}

// hello -> "hello"
def renderString(s: String): String = {
"\"" + s + "\""
}

// hello there -> `hello there`
def renderEscaped(s: String): String = {
"`" + s + "`"
}
}

case class MediaTypeInfo(
mainType: String,
subType: String,
compressible: Boolean,
extensions: List[String],
) {

def binary: Boolean = {
// If the main type is "image", "video", "audio", or "application", it is likely binary.
// Additionally, if the MIME type is not compressible, it is likely binary.
mainType match {
case "image" | "video" | "audio" | "font" | "model" | "multipart" => true
case "application" => !subType.startsWith("xml") && !subType.endsWith("json") && !subType.endsWith("javascript")
case "text" => false
case _ => !compressible
}
}

// Renders the media type info as a Scala code snippet
def render: String = {
val extensionsString =
if (extensions.isEmpty) ""
else s", ${extensions.map { string => s""""$string"""" }.mkString("List(", ", ", ")")}"

s"""
lazy val `${subType}`: MediaType =
new MediaType("$mainType", "$subType", compressible = $compressible, binary = $binary$extensionsString)
"""
}
}

object MediaTypeInfo {
def fromMimeDb(mimeDb: MimeDb): List[MediaTypeInfo] = {
mimeDb.mimeTypes.map { case (mimeType, details) =>
val Array(mainType, subType) = mimeType.split('/')
MediaTypeInfo(mainType, subType, details.compressible.getOrElse(false), details.extensions.getOrElse(List.empty))
}.toList
}
}

// Renders a group of media types as a Scala code snippet
case class MediaTypeGroup(
mainType: String,
subTypes: List[MediaTypeInfo],
) {
def render: String = {

s"""
object ${RenderUtils.snakeCase(mainType)} {
${subTypes.map(_.render).mkString("\n")}
lazy val all: List[MediaType] = List(${subTypes.map(t => RenderUtils.renderEscaped(t.subType)).mkString(", ")})
lazy val any: MediaType = new MediaType("$mainType", "*")
}
"""
}
}

object GenerateMediaTypes extends ZIOAppDefault {
val run =
for {
mimeDb <- MimeDb.fetch
mediaTypes = MediaTypeInfo.fromMimeDb(mimeDb)
mediaTypeGroups =
mediaTypes
.groupBy(_.mainType)
.map { case (mainType, subTypes) =>
MediaTypeGroup(mainType, subTypes)
}
.toList
file = MediaTypeFile(mediaTypeGroups)
mediaTypesPath = "../zio-http/shared/src/main/scala/zio/http/MediaTypes.scala"
_ <- ZIO.writeFile(mediaTypesPath, file.render)
} yield ()
}

// Renders a list of media type groups as a Scala code snippet
case class MediaTypeFile(
groups: List[MediaTypeGroup],
) {
def render: String = {
s"""
// ⚠️ HEY YOU! IMPORTANT MESSAGE ⚠️
// ==============================
//
// THIS FILE IS AUTOMATICALLY GENERATED BY `GenerateMediaTypes.scala`
// So don't go editing it now, you hear? Otherwise your changes will
// be overwritten the next time someone runs `sbt generateMediaTypes`

package zio.http

private[zio] trait MediaTypes {
private[zio] lazy val allMediaTypes: List[MediaType] =
${groups.map(main => s"${RenderUtils.snakeCase(main.mainType)}.all").mkString(" ++ ")}
lazy val any: MediaType = new MediaType("*", "*")

${groups.map(_.render).mkString("\n\n")}
}
"""
}
}
4 changes: 2 additions & 2 deletions zio-http/jvm/src/test/scala/zio/http/ContentTypeSpec.scala
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ object ContentTypeSpec extends HttpRunnableSpec {
val res =
Handler.fromResource("TestFile3.js").sandbox.toRoutes.deploy(Request()).map(_.header(Header.ContentType))
assertZIO(res)(
isSome(equalTo(Header.ContentType(MediaType.application.`javascript`, charset = Some(Charsets.Utf8)))),
isSome(equalTo(Header.ContentType(MediaType.text.`javascript`, charset = Some(Charsets.Utf8)))),
)
},
test("no extension") {
Expand All @@ -50,7 +50,7 @@ object ContentTypeSpec extends HttpRunnableSpec {
test("mp3") {
val res =
Handler.fromResource("TestFile6.mp3").sandbox.toRoutes.deploy(Request()).map(_.header(Header.ContentType))
assertZIO(res)(isSome(equalTo(Header.ContentType(MediaType.audio.`mpeg`))))
assertZIO(res)(isSome(equalTo(Header.ContentType(MediaType.audio.`mp3`))))
},
test("unidentified extension") {
val res =
Expand Down
Loading
Loading