Skip to content

Commit

Permalink
Merge pull request #1455 from disneystreaming/protobuf-support
Browse files Browse the repository at this point in the history
Protobuf support
  • Loading branch information
Baccata authored Apr 15, 2024
2 parents 189df10 + 4d18e58 commit b6c7897
Show file tree
Hide file tree
Showing 25 changed files with 2,416 additions and 13 deletions.
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
# 0.18.16

* Adds a `smithy4s-protobuf` module, containing derivation logic for protobuf codecs. See https://github.com/disneystreaming/smithy4s/pull/1455
* Add support for converting smithy4s services and schemas to smithy models
* Add `smithy4s.meta#only` annotation allowing to filter operations in
* Add `smithy4s.meta#only` annotation allowing to filter operations in
services, intended to reduce the amount of code generated from AWS specs

# 0.18.15
Expand Down
58 changes: 53 additions & 5 deletions build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ lazy val allModules = Seq(
decline,
codegenPlugin,
benchmark,
protobuf,
protocol,
protocolTests,
`aws-kernel`,
Expand All @@ -88,7 +89,8 @@ lazy val docs =
`aws-http4s` % "compile -> compile",
complianceTests,
dynamic,
bootstrapped
bootstrapped,
protobuf
)
.settings(
mdocIn := (ThisBuild / baseDirectory).value / "modules" / "docs" / "markdown",
Expand Down Expand Up @@ -645,6 +647,37 @@ lazy val xml = projectMatrix
.jsPlatform(allJsScalaVersions, jsDimSettings)
.nativePlatform(allNativeScalaVersions, nativeDimSettings)

/**
* Module that contains protobuf encoders/decoders for the generated
* types.
*/
lazy val protobuf = projectMatrix
.in(file("modules/protobuf"))
.dependsOn(
core,
bootstrapped % "test->test",
scalacheck % "test -> compile"
)
.settings(
isMimaEnabled := false,
libraryDependencies ++= munitDeps.value,
libraryDependencies ++= {
if (virtualAxes.value.contains(VirtualAxis.jvm))
Seq(
"com.google.protobuf" % "protobuf-java" % "3.24.0",
"com.google.protobuf" % "protobuf-java-util" % "3.24.0" % Test
)
else
Seq(
"com.thesamet.scalapb" %% "protobuf-runtime-scala" % "0.8.14"
)
},
Test / fork := virtualAxes.value.contains(VirtualAxis.jvm)
)
.jvmPlatform(allJvmScalaVersions, jvmDimSettings)
.jsPlatform(allJsScalaVersions, jsDimSettings)
.nativePlatform(allNativeScalaVersions, nativeDimSettings)

/**
* Module that contains common code which relies on fs2.
*/
Expand Down Expand Up @@ -858,7 +891,20 @@ lazy val bootstrapped = projectMatrix
.disablePlugins(ScalafixPlugin)
.disablePlugins(HeaderPlugin)
.settings(
Test / fork := true,
// Setting ScalaPB to generate Scala code from proto files generated by
// smithy4s
Compile / PB.generate := {
// running smithy codegen before scalapb codegen to have the translated proto
genSmithyResources(Compile).taskValue
(Compile / PB.generate).value
},
Compile / PB.protoSources ++= Seq(
exampleGeneratedResourcesOutput.value
),
Compile / PB.targets := Seq(
scalapb.gen() -> (Compile / sourceManaged).value / "scalapb"
),
Test / fork := virtualAxes.value.contains(VirtualAxis.jvm),
exampleGeneratedOutput := (ThisBuild / baseDirectory).value / "modules" / "bootstrapped" / "src" / "generated",
exampleGeneratedResourcesOutput := (Compile / resourceDirectory).value,
cleanFiles ++= Seq(
Expand Down Expand Up @@ -907,7 +953,9 @@ lazy val bootstrapped = projectMatrix
libraryDependencies ++=
munitDeps.value ++ Seq(
Dependencies.Cats.core.value % Test,
Dependencies.Weaver.cats.value % Test
Dependencies.Weaver.cats.value % Test,
"com.thesamet.scalapb" %% "scalapb-runtime" % scalapb.compiler.Version.scalapbVersion % "protobuf",
Dependencies.Alloy.protobuf % "protobuf-src"
)
)
.jvmPlatform(allJvmScalaVersions, jvmDimSettings)
Expand Down Expand Up @@ -977,8 +1025,8 @@ lazy val `aws-sandbox` = projectMatrix

def genSmithy(config: Configuration) = Def.settings(
Seq(
config / sourceGenerators := Seq(genSmithyScala(config).taskValue),
config / resourceGenerators := Seq(genSmithyResources(config).taskValue)
config / sourceGenerators ++= Seq(genSmithyScala(config).taskValue),
config / resourceGenerators ++= Seq(genSmithyResources(config).taskValue)
)
)
def genSmithyScala(config: Configuration) = genSmithyImpl(config).map(_._1)
Expand Down
11 changes: 11 additions & 0 deletions modules/bootstrapped/test/src-js/smithy4s/TimestampSpec.scala
Original file line number Diff line number Diff line change
Expand Up @@ -183,4 +183,15 @@ class TimestampSpec() extends munit.FunSuite with munit.ScalaCheckSuite {
expect.same(formatted, expected)
}
}

property("Convert to/from epoch milliseconds") {
forAll { (d: Date) =>
val epochMilli = d.valueOf().toLong
val ts = Timestamp.fromDate(d)
val tsEpochMilli = ts.epochMilli
val tsFromEpochMilli = Timestamp.fromEpochMilli(epochMilli)
expect.same(tsEpochMilli, epochMilli)
expect.same(tsFromEpochMilli, ts)
}
}
}
16 changes: 16 additions & 0 deletions modules/bootstrapped/test/src-jvm/smithy4s/TimestampSpec.scala
Original file line number Diff line number Diff line change
Expand Up @@ -186,4 +186,20 @@ class TimestampSpec() extends munit.FunSuite with munit.ScalaCheckSuite {
expect.same(formatted, expected)
}
}

property("Convert to/from epoch milliseconds") {
forAll { (i: Instant) =>
val ts = Timestamp.fromInstant(i)
val epochMilli = i.toEpochMilli()
val tsEpochMilli = ts.epochMilli

expect.same(tsEpochMilli, epochMilli)

val strippedInstant = Instant.ofEpochMilli(i.toEpochMilli)
val tsFromStrippedInstant = Timestamp.fromInstant(strippedInstant)
val tsFromEpochMilli = Timestamp.fromEpochMilli(epochMilli)

expect.same(tsFromEpochMilli, tsFromStrippedInstant)
}
}
}
15 changes: 9 additions & 6 deletions modules/core/src-js/smithy4s/Timestamp.scala
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ import scalajs.js.Date
import scala.util.control.{NoStackTrace, NonFatal}

case class Timestamp private (epochSecond: Long, nano: Int) {

def epochMilli: Long = epochSecond * 1000 + nano / 1000000

def isAfter(other: Timestamp): Boolean = {
val diff = epochSecond - other.epochSecond
diff > 0 || diff == 0 && nano > other.nano
Expand Down Expand Up @@ -255,16 +258,16 @@ object Timestamp {
}

def fromEpochSecond(epochSecond: Long): Timestamp = Timestamp(epochSecond, 0)

/** JS platform only method */
def fromDate(x: Date): Timestamp = {
val currentMillis = x.valueOf()
def fromEpochMilli(epochMilli: Long): Timestamp = {
Timestamp(
(currentMillis / 1000).toLong,
(currentMillis % 1000).toInt * 1000000
(epochMilli / 1000),
(epochMilli % 1000).toInt * 1000000
)
}

/** JS platform only method */
def fromDate(x: Date): Timestamp = fromEpochMilli(x.valueOf().toLong)

def nowUTC(): Timestamp = fromDate(new Date())

def parse(string: String, format: TimestampFormat): Option[Timestamp] = try {
Expand Down
9 changes: 9 additions & 0 deletions modules/core/src-jvm-native/smithy4s/Timestamp.scala
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ import scala.util.control.{NoStackTrace, NonFatal}

case class Timestamp private (epochSecond: Long, nano: Int)
extends TimestampPlatform {

def epochMilli: Long = epochSecond * 1000 + nano / 1000000

def isAfter(other: Timestamp): Boolean = {
val diff = epochSecond - other.epochSecond
diff > 0 || diff == 0 && nano > other.nano
Expand Down Expand Up @@ -172,6 +175,12 @@ object Timestamp extends TimestampCompanionPlatform {

val epoch = Timestamp(0, 0)

def fromEpochMilli(epochMilli: Long): Timestamp = {
val secs = java.lang.Math.floorDiv(epochMilli, 1000)
val mos = java.lang.Math.floorMod(epochMilli, 1000)
Timestamp(secs, (mos * 1000000).toInt)
}

private val digits: Array[Short] = Array(
0x3030, 0x3130, 0x3230, 0x3330, 0x3430, 0x3530, 0x3630, 0x3730, 0x3830,
0x3930, 0x3031, 0x3131, 0x3231, 0x3331, 0x3431, 0x3531, 0x3631, 0x3731,
Expand Down
156 changes: 156 additions & 0 deletions modules/docs/markdown/02.1-serialisation/01-serialisation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
---
sidebar_label: Serialisation overview
title: Serialisation overview
---

The code generated by Smithy4s is strictly **protocol agnostic**. One implication is that the data-types generated by Smithy4s are not tied to any particular serialisation format or third-party library. Instead, Smithy4s generates an instance of a `smithy4s.schema.Schema` for each data-type (see [the relevant section](../05-design/02-schemas.md)). From this schema can be derived encoders and decoders for virtually any serialisation format.

Smithy4s provides opt-in modules implementing serialisation in a bunch of formats, including `JSON`, `XML` and `Protobuf`. The modules cross-compile to all combinations of platforms (JVM/JS/Native) and scala-versions supported by Smithy4s.

### Document (JSON-like adt)

The `smithy4s-core` module provides a `smithy4s.Document` datatype that is used in code-generation when [document](https://smithy.io/2.0/spec/simple-types.html#document) shapes are used in smithy. `Document` is effectively a JSON ADT, and can be easily converted to from other ADTs, such as the one provided by the [Circe library](https://circe.github.io/circe/).

`Document` also comes with its own Encoder and Decoder construct, for which instances can be derived for every datatype generated by Smithy4s.

```scala mdoc:reset
import smithy4s.example.hello.Person
import smithy4s.Document

val personEncoder = Document.Encoder.fromSchema(Person.schema)
val personDocument = personEncoder.encode(Person(name = "John Doe"))

val personDecoder = Document.Decoder.fromSchema(Person.schema)
val maybePerson = personDecoder.decode(personDocument)
```

By default, smithy4s Documents abide by the same semantics as `smithy4s-json` (see section below).

It is worth noting that, although `Document` is isomorphic to a JSON ADT, its `.toString` is not valid JSON. Likewise, the `smithy4s-core` module does not contain logic to parse JSON strings into Documents. In order to read/write Documents from/to JSON strings, you need the `smithy4s-json` module. The `smithy4s.json.Json` entry-point contains methods that work with Documents.

### JSON

The `smithy4s-json` module provides [jsoniter-based](https://github.com/plokhotnyuk/jsoniter-scala) encoders/decoders that can read/write generated data-types from/to JSON bytes/strings, without an intermediate JSON ADT. The performance of this module is very competitive are [very competitive](https://plokhotnyuk.github.io/jsoniter-scala/) in the Scala ecosystem.

This module is provided at the following coordinates :

```
sbt : "com.disneystreaming.smithy4s" %% "smithy4s-json" % "@VERSION@"
mill : "com.disneystreaming.smithy4s::smithy4s-json:@VERSION@"
```

The entrypoint for JSON parsing/writing is `smithy4s.json.Json`. See below for example usage.

```scala mdoc:reset
import smithy4s.example.hello.Person

import smithy4s.Blob
import smithy4s.json.Json

val personEncoder = Json.payloadCodecs.encoders.fromSchema(Person.schema)
val personJSON = personEncoder.encode(Person(name = "John Doe")).toUTF8String

val personDecoder = Json.payloadCodecs.decoders.fromSchema(Person.schema)
val maybePerson = personDecoder.decode(Blob(personJSON))
```

By default, `smithy4s-json` abides by the semantics of :

* [official smithy traits], including:
* [jsonName](https://smithy.io/2.0/spec/protocol-traits.html#jsonname-trait)
* [timestampFormat](https://smithy.io/2.0/spec/protocol-traits.html#timestampformat-trait)
* [sparse](https://smithy.io/2.0/spec/type-refinement-traits.html#sparse-trait)
* [required](https://smithy.io/2.0/spec/type-refinement-traits.html#required-trait)
* [default](https://smithy.io/2.0/spec/type-refinement-traits.html#default-value-serialization). It is worth noting that, by default, Smithy4s chooses to not serialise default values if the when the member is optional.
* [alloy traits](https://github.com/disneystreaming/alloy/blob/main/docs/serialisation/json.md)


### XML

The `smithy4s-xml` module provides [fs2-data](https://fs2-data.gnieh.org/documentation/xml/) encoders/decoders that can read/write generated data-types from/to XML bytes/strings. It is provided at the following coordinates :

```
sbt : "com.disneystreaming.smithy4s" %% "smithy4s-xml" % "@VERSION@"
mill : "com.disneystreaming.smithy4s::smithy4s-xml:@VERSION@"
```

The entrypoint for `smithy4s.xml.Xml`. See below for example usage.

```scala mdoc:reset
import smithy4s.example.hello.Person

import smithy4s.Blob
import smithy4s.xml.Xml

val personEncoder = Xml.encoders.fromSchema(Person.schema)
val personXML = personEncoder.encode(Person(name = "John Doe")).toUTF8String

val personDecoder = Xml.decoders.fromSchema(Person.schema)
val maybePerson = personDecoder.decode(Blob(personXML))
```

By default, `smithy4s-xml` abides by the semantics of :

* [official XML-related smithy traits](https://smithy.io/2.0/spec/protocol-traits.html#xml-bindings)

### Protobuf

The `smithy4s-protobuf` module provides [protocol-buffers](https://protobuf.dev/) codecs that can read/write generated data-types from protobuf-encoded bytes.

```
sbt : "com.disneystreaming.smithy4s" %% "smithy4s-protobuf" % "@VERSION@"
mill : "com.disneystreaming.smithy4s::smithy4s-protobuf:@VERSION@"
```

The entrypoint for XML parsing/writing is `smithy4s.protobuf.Protobuf`. See below for example usage.

```scala mdoc:reset
import smithy4s.example.hello.Person
import smithy4s.protobuf.Protobuf

val personCodec = Protobuf.codecs.fromSchema(Person.schema)
val personBytes = personCodec.writeBlob(Person(name = "John Doe"))
val maybePerson = personCodec.readBlob(personBytes)
```

By default, `smithy4s-protobuf` abides by the semantics of :

* [alloy protobuf traits](https://github.com/disneystreaming/alloy/blob/main/docs/serialisation/protobuf.md). These semantics are the exact same semantics that [smithy-translate](https://github.com/disneystreaming/smithy-translate) uses to translate smithy to protobuf. This implies that the Smithy4s protobuf codecs are compatible with the codecs of other protobuf tools, generated from the .proto files resulting from running smithy through smithy-translate. In short, Smithy4s and [ScalaPB](https://github.com/scalapb/ScalaPB) can talk to each other : the ScalaPB codecs generated from protobuf after a translation from smithy are able to decode binary data produced by Smithy4s protobuf codecs (and vice versa).


```
┌────────────────────┐ ┌────────────────────┐
│ │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ Smithy IDL ├────────────────────────► Protobuf IDL │
│ │ smithy-translate │ │
│ │ │ │
│ │ │ │
│ │ │ │
└─────────┬──────────┘ └─────────┬──────────┘
│ │
│ │
│ │
│ │
│ │
│ │
│ Smithy4s codegen │ ScalaPB codegen
│ │
│ │
│ │
│ │
│ │
┌─────────▼──────────┐ ┌─────────▼──────────┐
│ │ │ │
│ │ │ │
│ │ │ │
│ ◄────────────────────────┤ │
│ Smithy4s code │ Runtime communication │ ScalaPB code │
│ ├────────────────────────► │
│ │ │ │
│ │ │ │
│ │ │ │
└────────────────────┘ └────────────────────┘
```
3 changes: 3 additions & 0 deletions modules/docs/markdown/02.1-serialisation/_category_.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"label": "Serialisation"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/*
* Copyright 2021-2024 Disney Streaming
*
* Licensed under the Tomorrow Open Source Technology License, Version 1.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://disneystreaming.github.io/TOST-1.0.txt
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package smithy4s.protobuf

import smithy4s.Blob
import com.google.protobuf.CodedInputStream

private[protobuf] object CodecInputStreamPlatform {

private[protobuf] def blobToCodecInputStream(blob: Blob): CodedInputStream =
blob match {
case asb: Blob.ArraySliceBlob =>
CodedInputStream.newInstance(asb.arr, asb.offset, asb.length)
case bbb: Blob.ByteBufferBlob =>
CodedInputStream.newInstance(bbb.toArray)
case qb: Blob.QueueBlob =>
CodedInputStream.newInstance(qb.toArray)
}

}
Loading

0 comments on commit b6c7897

Please sign in to comment.