diff --git a/.github/workflows/compatibility.yaml b/.github/workflows/compatibility.yaml new file mode 100644 index 0000000000..46dce22f52 --- /dev/null +++ b/.github/workflows/compatibility.yaml @@ -0,0 +1,37 @@ +name: Federation Specification Compatibility Test + +on: + pull_request: + branches: + - series/2.x + +jobs: + compatibility: + runs-on: ubuntu-latest + steps: + - name: Checkout current branch (full) + uses: actions/checkout@v2 + with: + fetch-depth: 0 + + - name: Setup Java (temurin@17) + uses: actions/setup-java@v2 + with: + distribution: 'temurin' + java-version: '17' + + - name: Compatibility Test + uses: apollographql/federation-subgraph-compatibility@v2 + with: + compose: 'apollo-compatibility/docker-compose.yaml' + schema: 'apollo-compatibility/schema.graphql' + path: 'graphql' + port: 4001 + debug: true + token: ${{ secrets.GITHUB_TOKEN }} + # Boolean flag to indicate whether any failing test should fail the script + failOnWarning: false + # Boolean flag to indicate whether any required test should fail the script + failOnRequired: false + # Working directory to run the test from + workingDirectory: '' \ No newline at end of file diff --git a/apollo-compatibility/Dockerfile b/apollo-compatibility/Dockerfile new file mode 100644 index 0000000000..39d3940abe --- /dev/null +++ b/apollo-compatibility/Dockerfile @@ -0,0 +1,12 @@ +FROM hseeberger/scala-sbt:17.0.2_1.9.9_3.1.1 AS build + +WORKDIR /build +COPY build.sbt . +COPY project ./project +COPY core ./core +COPY federation ./federation +COPY adapters/quick ./adapters/quick +COPY macros ./macros +COPY apollo-compatibility/src ./apollo-compatibility/src +EXPOSE 4001 +CMD sbt apollo-compatibility/run \ No newline at end of file diff --git a/apollo-compatibility/README.md b/apollo-compatibility/README.md new file mode 100644 index 0000000000..3fec355afe --- /dev/null +++ b/apollo-compatibility/README.md @@ -0,0 +1,18 @@ +# federated subgraph to test apollo federation spec compatibility + +Implementation of a federated subgraph aligned to the requirements outlined in [apollo-federation-subgraph-compatibility](https://github.com/apollographql/apollo-federation-subgraph-compatibility). + +The subgraph can be used to verify compability against [Apollo Federation Subgraph Specification](https://www.apollographql.com/docs/federation/subgraph-spec). + +### Run compatibility tests +Execute the following command from the root of the repo + +``` +npx @apollo/federation-subgraph-compatibility docker --compose apollo-compatibility/docker-compose.yml --schema apollo-compatibility/schema.graphql +``` + +### Printing the GraphQL Schema (SDL) + +``` +sbt "apollo-compability/run printSchema" +``` \ No newline at end of file diff --git a/apollo-compatibility/docker-compose.yaml b/apollo-compatibility/docker-compose.yaml new file mode 100644 index 0000000000..deb5596a50 --- /dev/null +++ b/apollo-compatibility/docker-compose.yaml @@ -0,0 +1,7 @@ +services: + products: + build: + context: . + dockerfile: ./apollo-compatibility/Dockerfile + ports: + - 4001:4001 \ No newline at end of file diff --git a/apollo-compatibility/products.graphql b/apollo-compatibility/products.graphql new file mode 100644 index 0000000000..79b4848fa0 --- /dev/null +++ b/apollo-compatibility/products.graphql @@ -0,0 +1,83 @@ +extend schema +@link( + url: "https://specs.apollo.dev/federation/v2.3" + import: [ + "@composeDirective" + "@extends" + "@external" + "@key" + "@inaccessible" + "@interfaceObject" + "@override" + "@provides" + "@requires" + "@shareable" + "@tag" + ] +) +@link(url: "https://myspecs.dev/myCustomDirective/v1.0", import: ["@custom"]) +@composeDirective(name: "@custom") + +directive @custom on OBJECT + +type Product +@custom +@key(fields: "id") +@key(fields: "sku package") +@key(fields: "sku variation { id }") { + id: ID! + sku: String + package: String + variation: ProductVariation + dimensions: ProductDimension + createdBy: User @provides(fields: "totalProductsCreated") + notes: String @tag(name: "internal") + research: [ProductResearch!]! +} + +type DeprecatedProduct @key(fields: "sku package") { + sku: String! + package: String! + reason: String + createdBy: User +} + +type ProductVariation { + id: ID! +} + +type ProductResearch @key(fields: "study { caseNumber }") { + study: CaseStudy! + outcome: String +} + +type CaseStudy { + caseNumber: ID! + description: String +} + +type ProductDimension @shareable { + size: String + weight: Float + unit: String @inaccessible +} + +extend type Query { + product(id: ID!): Product + deprecatedProduct(sku: String!, package: String!): DeprecatedProduct + @deprecated(reason: "Use product query instead") +} + +extend type User @key(fields: "email") { + averageProductsCreatedPerYear: Int + @requires(fields: "totalProductsCreated yearsOfEmployment") + email: ID! @external + name: String @override(from: "users") + totalProductsCreated: Int @external + yearsOfEmployment: Int! @external +} + +type Inventory @interfaceObject @key(fields: "id") { + id: ID! + deprecatedProducts: [DeprecatedProduct!]! +} \ No newline at end of file diff --git a/apollo-compatibility/src/main/scala/Main.scala b/apollo-compatibility/src/main/scala/Main.scala new file mode 100644 index 0000000000..262b7f959e --- /dev/null +++ b/apollo-compatibility/src/main/scala/Main.scala @@ -0,0 +1,35 @@ +import caliban.CalibanError +import zio._ +import caliban.quick._ +import services.{ InventoryService, ProductService, UserService } +import zio.http.Server + +object Main extends ZIOAppDefault { + + def run = for { + args <- ZIOAppArgs.getArgs + _ <- (args match { + case Chunk("printSchema") => printSchema + case _ => runServer + }) + } yield () + + val printSchema = Console.printLine(ProductSchema.print) + + val runServer = { + val server: ZIO[ + ProductService with UserService with InventoryService with Server, + CalibanError.ValidationError, + Int + ] = (ProductSchema.api.toApp("/graphql") flatMap Server.install) + + (server *> Console.printLine("Press any key to exit...") *> Console.readLine).orDie + .provide( + Server.defaultWithPort(4001), + ProductService.inMemory, + UserService.inMemory, + InventoryService.inMemory + ) + } + +} diff --git a/apollo-compatibility/src/main/scala/ProductSchema.scala b/apollo-compatibility/src/main/scala/ProductSchema.scala new file mode 100644 index 0000000000..c602d0731a --- /dev/null +++ b/apollo-compatibility/src/main/scala/ProductSchema.scala @@ -0,0 +1,103 @@ +import caliban._ +import caliban.federation.EntityResolver +import caliban.federation.tracing.ApolloFederatedTracing +import caliban.introspection.adt.{ __Directive, __DirectiveLocation } +import caliban.schema.Annotations.GQLDeprecated +import caliban.schema.{ GenericSchema, Schema } +import models._ +import services.{ InventoryService, ProductService, UserService } +import zio.query.ZQuery +import zio.{ URIO, ZIO } + +case class Query( + product: QueryProductArgs => URIO[ProductService, Option[models.Product]], + @GQLDeprecated("Use product query instead") deprecatedProduct: DeprecatedProductArgs => URIO[ + ProductService, + Option[DeprecatedProduct] + ] +) + +object Query { + object apiSchema extends GenericSchema[ProductService with UserService] + implicit val schema: Schema[ProductService with UserService, Query] = apiSchema.gen +} + +object ProductSchema extends GenericSchema[ProductService with UserService] { + val productResolver: EntityResolver[ProductService with UserService] = + EntityResolver[ProductService with UserService, ProductArgs, models.Product] { + case ProductArgs.IdOnly(id) => + ZQuery.serviceWithZIO[ProductService](_.getProductById(id.id)) + case ProductArgs.SkuAndPackage(sku, p) => + ZQuery.serviceWithZIO[ProductService](_.getProductBySkuAndPackage(sku, p)) + case ProductArgs.SkuAndVariationId(sku, variation) => + ZQuery.serviceWithZIO[ProductService](_.getProductBySkuAndVariationId(sku, variation.id.id)) + } + + val userResolver: EntityResolver[UserService with ProductService] = + EntityResolver[UserService with ProductService, UserArgs, User] { args => + ZQuery.serviceWithZIO[UserService](_.getUser) + } + + val productResearchResolver: EntityResolver[UserService with ProductService] = + EntityResolver.from[ProductResearchArgs] { args => + ZQuery.some( + ProductResearch( + CaseStudy(caseNumber = args.study.caseNumber, Some("Federation Study")), + None + ) + ) + } + + val deprecatedProductResolver: EntityResolver[ProductService with UserService] = + EntityResolver[ProductService with UserService, DeprecatedProductArgs, DeprecatedProduct] { args => + ZQuery.some( + models.DeprecatedProduct( + sku = "apollo-federation-v1", + `package` = "@apollo/federation-v1", + reason = Some("Migrate to Federation V2"), + createdBy = ZIO.serviceWithZIO[UserService](_.getUser) + ) + ) + } + + val inventoryResolver: EntityResolver[InventoryService with UserService] = + EntityResolver[InventoryService with UserService, InventoryArgs, Inventory] { args => + ZQuery.serviceWith[InventoryService](_.getById(args.id.id)) + } + + val api: GraphQL[ProductService with UserService with InventoryService] = + graphQL( + RootResolver( + Query( + args => ZIO.serviceWithZIO[ProductService](_.getProductById(args.id.id)), + args => + ZIO.some( + models.DeprecatedProduct( + sku = "apollo-federation-v1", + `package` = "@apollo/federation-v1", + reason = Some("Migrate to Federation V2"), + createdBy = ZIO.serviceWithZIO[UserService](_.getUser) + ) + ) + ) + ), + directives = List( + __Directive( + "custom", + None, + Set(__DirectiveLocation.OBJECT), + _ => Nil, + isRepeatable = false + ) + ) + ) @@ federated( + productResolver, + userResolver, + productResearchResolver, + deprecatedProductResolver, + inventoryResolver + ) @@ ApolloFederatedTracing.wrapper() + + val print = api.render + +} diff --git a/apollo-compatibility/src/main/scala/models/CaseStudy.scala b/apollo-compatibility/src/main/scala/models/CaseStudy.scala new file mode 100644 index 0000000000..573245d9f2 --- /dev/null +++ b/apollo-compatibility/src/main/scala/models/CaseStudy.scala @@ -0,0 +1,12 @@ +package models + +import caliban.schema.Schema + +case class CaseStudy( + caseNumber: ID, + description: Option[String] +) + +object CaseStudy { + implicit val schema: Schema[Any, CaseStudy] = Schema.gen +} diff --git a/apollo-compatibility/src/main/scala/models/CaseStudyArgs.scala b/apollo-compatibility/src/main/scala/models/CaseStudyArgs.scala new file mode 100644 index 0000000000..eb056ecb61 --- /dev/null +++ b/apollo-compatibility/src/main/scala/models/CaseStudyArgs.scala @@ -0,0 +1,10 @@ +package models + +import caliban.schema.{ ArgBuilder, Schema } + +case class CaseStudyArgs(caseNumber: ID) + +object CaseStudyArgs { + implicit val schema: Schema[Any, CaseStudyArgs] = Schema.gen + implicit val argBuilder: ArgBuilder[CaseStudyArgs] = ArgBuilder.gen +} diff --git a/apollo-compatibility/src/main/scala/models/Custom.scala b/apollo-compatibility/src/main/scala/models/Custom.scala new file mode 100644 index 0000000000..8ad77d2b9e --- /dev/null +++ b/apollo-compatibility/src/main/scala/models/Custom.scala @@ -0,0 +1,6 @@ +package models + +import caliban.parsing.adt.Directive +import caliban.schema.Annotations.GQLDirective + +case class Custom() extends GQLDirective(Directive("custom")) diff --git a/apollo-compatibility/src/main/scala/models/DeprecatedProduct.scala b/apollo-compatibility/src/main/scala/models/DeprecatedProduct.scala new file mode 100644 index 0000000000..9bf6bcbbd9 --- /dev/null +++ b/apollo-compatibility/src/main/scala/models/DeprecatedProduct.scala @@ -0,0 +1,19 @@ +package models + +import caliban.schema.{ GenericSchema, Schema } +import services.UserService +import zio.URIO + +@GQLKey("sku package") +case class DeprecatedProduct( + sku: String, + `package`: String, + reason: Option[String], + createdBy: URIO[UserService, Option[User]] +) + +object DeprecatedProduct { + object apiSchema extends GenericSchema[UserService] + + implicit val schema: Schema[UserService, DeprecatedProduct] = apiSchema.gen[UserService, DeprecatedProduct] +} diff --git a/apollo-compatibility/src/main/scala/models/DeprecatedProductArgs.scala b/apollo-compatibility/src/main/scala/models/DeprecatedProductArgs.scala new file mode 100644 index 0000000000..bc005b22bd --- /dev/null +++ b/apollo-compatibility/src/main/scala/models/DeprecatedProductArgs.scala @@ -0,0 +1,13 @@ +package models + +import caliban.schema.{ ArgBuilder, Schema } + +case class DeprecatedProductArgs( + sku: String, + `package`: String +) + +object DeprecatedProductArgs { + implicit val schema: Schema[Any, DeprecatedProductArgs] = Schema.gen + implicit val argBuilder: ArgBuilder[DeprecatedProductArgs] = ArgBuilder.gen[DeprecatedProductArgs] +} diff --git a/apollo-compatibility/src/main/scala/models/ID.scala b/apollo-compatibility/src/main/scala/models/ID.scala new file mode 100644 index 0000000000..59404f6fe4 --- /dev/null +++ b/apollo-compatibility/src/main/scala/models/ID.scala @@ -0,0 +1,11 @@ +package models + +import caliban.schema.{ ArgBuilder, Schema } +import caliban.Value.StringValue + +case class ID(id: String) extends AnyVal + +object ID { + implicit val schema: Schema[Any, ID] = Schema.scalarSchema[ID]("ID", None, None, None, id => StringValue(id.id)) + implicit val argBuilder: ArgBuilder[ID] = ArgBuilder.string.map(ID(_)) +} diff --git a/apollo-compatibility/src/main/scala/models/Inventory.scala b/apollo-compatibility/src/main/scala/models/Inventory.scala new file mode 100644 index 0000000000..582792d376 --- /dev/null +++ b/apollo-compatibility/src/main/scala/models/Inventory.scala @@ -0,0 +1,16 @@ +package models + +import caliban.schema.{ GenericSchema, Schema } +import services.{ InventoryService, UserService } + +@GQLInterfaceObject +@GQLKey("email") +case class Inventory( + id: ID, + deprecatedProducts: List[DeprecatedProduct] +) + +object Inventory { + object genSchema extends GenericSchema[InventoryService with UserService] + implicit val schema: Schema[InventoryService with UserService, Inventory] = genSchema.gen +} diff --git a/apollo-compatibility/src/main/scala/models/InventoryArgs.scala b/apollo-compatibility/src/main/scala/models/InventoryArgs.scala new file mode 100644 index 0000000000..f672a6d9de --- /dev/null +++ b/apollo-compatibility/src/main/scala/models/InventoryArgs.scala @@ -0,0 +1,10 @@ +package models + +import caliban.schema.{ ArgBuilder, Schema } + +case class InventoryArgs(id: ID) + +object InventoryArgs { + implicit val schema: Schema[Any, InventoryArgs] = Schema.gen + implicit val argBuilder: ArgBuilder[InventoryArgs] = ArgBuilder.gen +} diff --git a/apollo-compatibility/src/main/scala/models/MyFederation.scala b/apollo-compatibility/src/main/scala/models/MyFederation.scala new file mode 100644 index 0000000000..142c66a696 --- /dev/null +++ b/apollo-compatibility/src/main/scala/models/MyFederation.scala @@ -0,0 +1,14 @@ +package models + +import caliban.federation.v2x._ + +abstract class MyFederation + extends caliban.federation.v2x.FederationV2( + Versions.v2_3 :: Link( + "https://myspecs.dev/myCustomDirective/v1.0", + List( + Import("@custom") + ) + ) :: ComposeDirective("@custom") :: Nil + ) + with FederationDirectivesV2_3 diff --git a/apollo-compatibility/src/main/scala/models/Product.scala b/apollo-compatibility/src/main/scala/models/Product.scala new file mode 100644 index 0000000000..acfea6a0cd --- /dev/null +++ b/apollo-compatibility/src/main/scala/models/Product.scala @@ -0,0 +1,25 @@ +package models + +import caliban.schema.Schema +import zio.UIO + +@GQLKey("id") +@GQLKey("sku package") +@GQLKey("sku variation { id }") +@Custom +case class Product( + id: ID, + sku: Option[String], + `package`: Option[String], + variation: Option[ProductVariation], + dimensions: Option[ProductDimension], + @GQLProvides("totalProductsCreated") createdBy: UIO[Option[User]], + @GQLTag("internal") notes: Option[String], + research: List[ProductResearch] +) + +object Product { + + implicit val schema: Schema[Any, Product] = Schema.gen + +} diff --git a/apollo-compatibility/src/main/scala/models/ProductArgs.scala b/apollo-compatibility/src/main/scala/models/ProductArgs.scala new file mode 100644 index 0000000000..94eb063a17 --- /dev/null +++ b/apollo-compatibility/src/main/scala/models/ProductArgs.scala @@ -0,0 +1,29 @@ +package models + +import caliban.InputValue +import caliban.schema.{ ArgBuilder, Schema } + +sealed trait ProductArgs + +object ProductArgs { + case class IdOnly(id: ID) extends ProductArgs + case class SkuAndPackage(sku: String, `package`: String) extends ProductArgs + case class SkuAndVariationId(sku: String, variation: ProductVariation) extends ProductArgs + + private implicit val variationArgs: ArgBuilder[ProductVariation] = ArgBuilder.gen[ProductVariation] + val idOnlyArgBuilder: ArgBuilder[IdOnly] = ArgBuilder.gen[IdOnly] + val skuAndPackageArgBuilder: ArgBuilder[SkuAndPackage] = ArgBuilder.gen[SkuAndPackage] + val skuAndVariationIdArgBuilder: ArgBuilder[SkuAndVariationId] = ArgBuilder.gen[SkuAndVariationId] + + implicit val argBuilder: ArgBuilder[ProductArgs] = (input: InputValue) => + (for { + error <- skuAndVariationIdArgBuilder.build(input).swap + _ <- skuAndPackageArgBuilder.build(input).swap + _ <- idOnlyArgBuilder.build(input).swap + } yield error).swap + + implicit val idOnlySchema: Schema[Any, ProductArgs.IdOnly] = Schema.gen + implicit val skuAndPackageSchema: Schema[Any, ProductArgs.SkuAndPackage] = Schema.gen + implicit val skuAndVariationIdSchema: Schema[Any, ProductArgs.SkuAndVariationId] = Schema.gen + +} diff --git a/apollo-compatibility/src/main/scala/models/ProductDimension.scala b/apollo-compatibility/src/main/scala/models/ProductDimension.scala new file mode 100644 index 0000000000..d5110051bf --- /dev/null +++ b/apollo-compatibility/src/main/scala/models/ProductDimension.scala @@ -0,0 +1,14 @@ +package models + +import caliban.schema.Schema + +@GQLShareable +case class ProductDimension( + size: Option[String], + weight: Option[Float], + @GQLInaccessible unit: Option[String] +) + +object ProductDimension { + implicit val schema: Schema[Any, ProductDimension] = Schema.gen +} diff --git a/apollo-compatibility/src/main/scala/models/ProductResearch.scala b/apollo-compatibility/src/main/scala/models/ProductResearch.scala new file mode 100644 index 0000000000..72cb7b30f5 --- /dev/null +++ b/apollo-compatibility/src/main/scala/models/ProductResearch.scala @@ -0,0 +1,13 @@ +package models + +import caliban.schema.Schema + +@GQLKey("study { caseNumber }") +case class ProductResearch( + study: CaseStudy, + outcome: Option[String] +) + +object ProductResearch { + implicit val schema: Schema[Any, ProductResearch] = Schema.gen +} diff --git a/apollo-compatibility/src/main/scala/models/ProductResearchArgs.scala b/apollo-compatibility/src/main/scala/models/ProductResearchArgs.scala new file mode 100644 index 0000000000..8e6a67897d --- /dev/null +++ b/apollo-compatibility/src/main/scala/models/ProductResearchArgs.scala @@ -0,0 +1,10 @@ +package models + +import caliban.schema.{ ArgBuilder, Schema } + +case class ProductResearchArgs(study: CaseStudyArgs) + +object ProductResearchArgs { + implicit val schema: Schema[Any, ProductResearchArgs] = Schema.gen + implicit val argBuilder: ArgBuilder[ProductResearchArgs] = ArgBuilder.gen +} diff --git a/apollo-compatibility/src/main/scala/models/ProductVariation.scala b/apollo-compatibility/src/main/scala/models/ProductVariation.scala new file mode 100644 index 0000000000..a97e716a0b --- /dev/null +++ b/apollo-compatibility/src/main/scala/models/ProductVariation.scala @@ -0,0 +1,11 @@ +package models + +import caliban.schema.Schema + +case class ProductVariation( + id: ID +) + +object ProductVariation { + implicit val schema: Schema[Any, ProductVariation] = Schema.gen +} diff --git a/apollo-compatibility/src/main/scala/models/QueryProductArgs.scala b/apollo-compatibility/src/main/scala/models/QueryProductArgs.scala new file mode 100644 index 0000000000..73982bfac5 --- /dev/null +++ b/apollo-compatibility/src/main/scala/models/QueryProductArgs.scala @@ -0,0 +1,10 @@ +package models + +import caliban.schema.{ ArgBuilder, Schema } + +case class QueryProductArgs(id: ID) + +object QueryProductArgs { + implicit val argBuilder: ArgBuilder[QueryProductArgs] = ArgBuilder.gen + implicit val schema: Schema[Any, QueryProductArgs] = Schema.gen +} diff --git a/apollo-compatibility/src/main/scala/models/User.scala b/apollo-compatibility/src/main/scala/models/User.scala new file mode 100644 index 0000000000..8b1d30e996 --- /dev/null +++ b/apollo-compatibility/src/main/scala/models/User.scala @@ -0,0 +1,17 @@ +package models + +import caliban.schema.Schema + +@GQLKey("email") +@GQLExtend +case class User( + @GQLExternal email: ID, + @GQLExternal totalProductsCreated: Option[Int], + @GQLOverride("users") name: Option[String], + @GQLRequires("totalProductsCreated yearsOfEmployment") averageProductsCreatedPerYear: Option[Int], + @GQLExternal yearsOfEmployment: Int +) + +object User { + implicit val schema: Schema[Any, User] = Schema.gen +} diff --git a/apollo-compatibility/src/main/scala/models/UserArgs.scala b/apollo-compatibility/src/main/scala/models/UserArgs.scala new file mode 100644 index 0000000000..27ef3319bf --- /dev/null +++ b/apollo-compatibility/src/main/scala/models/UserArgs.scala @@ -0,0 +1,10 @@ +package models + +import caliban.schema.{ ArgBuilder, Schema } + +case class UserArgs(email: ID) + +object UserArgs { + implicit val schema: Schema[Any, UserArgs] = Schema.gen + implicit val argBuilder: ArgBuilder[UserArgs] = ArgBuilder.gen +} diff --git a/apollo-compatibility/src/main/scala/models/package.scala b/apollo-compatibility/src/main/scala/models/package.scala new file mode 100644 index 0000000000..0a8d098dcd --- /dev/null +++ b/apollo-compatibility/src/main/scala/models/package.scala @@ -0,0 +1 @@ +package object models extends MyFederation diff --git a/apollo-compatibility/src/main/scala/services/InventoryService.scala b/apollo-compatibility/src/main/scala/services/InventoryService.scala new file mode 100644 index 0000000000..a60ce6d164 --- /dev/null +++ b/apollo-compatibility/src/main/scala/services/InventoryService.scala @@ -0,0 +1,30 @@ +package services + +import models.{ DeprecatedProduct, ID, Inventory } +import zio.{ ULayer, ZIO, ZLayer } + +trait InventoryService { + + def getById(id: String): Option[Inventory] + +} + +object InventoryService { + val inventory = List( + Inventory( + id = ID("apollo-oss"), + deprecatedProducts = List( + DeprecatedProduct( + sku = "apollo-federation-v1", + `package` = "@apollo/federation-v1", + reason = Some("Migrate to Federation V2"), + createdBy = ZIO.serviceWithZIO[UserService](_.getUser) + ) + ) + ) + ) + + val inMemory: ULayer[InventoryService] = ZLayer.succeed(new InventoryService { + def getById(id: String): Option[Inventory] = inventory.find(_.id.id == id) + }) +} diff --git a/apollo-compatibility/src/main/scala/services/ProductService.scala b/apollo-compatibility/src/main/scala/services/ProductService.scala new file mode 100644 index 0000000000..d6e828381a --- /dev/null +++ b/apollo-compatibility/src/main/scala/services/ProductService.scala @@ -0,0 +1,83 @@ +package services + +import zio.{ Ref, UIO, ZIO, ZLayer } + +trait ProductService { + def getProductById(id: String): UIO[Option[models.Product]] + def getProductBySkuAndPackage(sku: String, pack: String): UIO[Option[models.Product]] + def getProductBySkuAndVariationId(sku: String, variationId: String): UIO[Option[models.Product]] +} + +object ProductService { + val productsResearch = List( + models.ProductResearch( + study = models.CaseStudy(models.ID("1234"), Some("Federation Study")), + outcome = None + ), + models.ProductResearch( + study = models.CaseStudy(models.ID("1235"), Some("Studio Study")), + outcome = None + ) + ) + + val inMemory: ZLayer[Any, Nothing, ProductService] = + ZLayer( + Ref + .make( + List( + models.Product( + id = models.ID("apollo-federation"), + sku = Some("federation"), + `package` = Some("@apollo/federation"), + variation = Some(models.ProductVariation(models.ID("OSS"))), + dimensions = Some(models.ProductDimension(Some("small"), Some(1.0f), Some("kg"))), + createdBy = ZIO.some( + models.User( + models.ID("support@apollographql.com"), + Some(1337), + Some("Jane Smith"), + averageProductsCreatedPerYear = Some(1337 / 10), + yearsOfEmployment = 10 + ) + ), + notes = Some("This is a test product"), + research = productsResearch.init + ), + models.Product( + id = models.ID("apollo-studio"), + sku = Some("studio"), + `package` = Some(""), + variation = Some(models.ProductVariation(models.ID("platform"))), + dimensions = Some(models.ProductDimension(Some("small"), Some(1.0f), Some("kg"))), + createdBy = ZIO.some( + models.User( + models.ID("support@apollographql.com"), + Some(1337), + Some("Jane Smith"), + averageProductsCreatedPerYear = Some(1337 / 10), + yearsOfEmployment = 10 + ) + ), + notes = Some("This is a note"), + research = productsResearch.tail + ) + ) + ) + .map { products => + new ProductService { + override def getProductById(id: String): UIO[Option[models.Product]] = + products.get.map(_.find(_.id.id == id)) + + override def getProductBySkuAndPackage(sku: String, pack: String): UIO[Option[models.Product]] = + products.get.map(_.find(p => p.sku.contains(sku) && p.`package`.contains(pack))) + + override def getProductBySkuAndVariationId(sku: String, variationId: String): UIO[Option[models.Product]] = + products.get.map( + _.find(p => + p.sku.contains(sku) && p.variation.contains(models.ProductVariation(models.ID(variationId))) + ) + ) + } + } + ) +} diff --git a/apollo-compatibility/src/main/scala/services/UserService.scala b/apollo-compatibility/src/main/scala/services/UserService.scala new file mode 100644 index 0000000000..d37df0da0f --- /dev/null +++ b/apollo-compatibility/src/main/scala/services/UserService.scala @@ -0,0 +1,23 @@ +package services + +import models.{ ID, User } +import zio.{ UIO, ULayer, ZIO, ZLayer } + +trait UserService { + + def getUser: UIO[Option[User]] +} + +object UserService { + private val theUser = User( + averageProductsCreatedPerYear = Some(1337 / 10), + email = ID("support@apollographql.com"), + name = Some("Jane Smith"), + totalProductsCreated = Some(1337), + yearsOfEmployment = 10 + ) + + val inMemory: ULayer[UserService] = ZLayer.succeed(new UserService { + def getUser: zio.UIO[Option[User]] = ZIO.some(theUser) + }) +} diff --git a/build.sbt b/build.sbt index a67aed4eeb..befe38b657 100644 --- a/build.sbt +++ b/build.sbt @@ -98,7 +98,8 @@ lazy val allProjects: Seq[ProjectReference] = codegenSbt, federation, reporting, - tracing + tracing, + apolloCompatibility ) lazy val root = project @@ -575,6 +576,24 @@ lazy val examples = project tools ) +lazy val apolloCompatibility = + project + .in(file("apollo-compatibility")) + .settings(commonSettings) + .settings( + name := "apollo-compatibility", + publish / skip := true, + run / fork := true, + run / connectInput := true + ) + .settings( + skip := (scalaVersion.value != scala213), + ideSkipProject := (scalaVersion.value != scala213), + crossScalaVersions := Seq(scala213), + libraryDependencySchemes += "org.scala-lang.modules" %% "scala-java8-compat" % "always" + ) + .dependsOn(federation, core, quickAdapter) + lazy val reporting = project .in(file("reporting")) .settings(name := "caliban-reporting") diff --git a/docs/docs/optimization.html b/docs/docs/optimization.html index df1d5ad916..1df8563b29 100644 --- a/docs/docs/optimization.html +++ b/docs/docs/optimization.html @@ -37,7 +37,9 @@ (opens new window)

# Query optimization

A GraphQL query may request multiple fields that are using the same resolver. It's not a problem if the resolver is a simple value, but it can be less than optimal when the resolver runs an effect (such as reading from a database).

We might want to:

This is possible in Caliban using the ZQuery (opens new window) data type.

Additionally, one may want to perform optimizations based on the fields selected by the client. -This optimization can be achieved by field metadata from Caliban that can be referenced in your query classes.

# Introducing ZQuery

A ZQuery[R, E, A] is a purely functional description of an effectual query that may contain requests to one or more data sources. Similarly to ZIO[R, E, A], it requires an environment R, may fail with an E or succeed with an A. All requests that do not need to be performed sequentially will automatically be batched, allowing for aggressive data source specific optimizations. Requests will also automatically be deduplicated and cached.

This allows for writing queries in a high level, compositional style, with confidence that they will automatically be optimized. For example, consider the following query from a user service.

val getAllUserIds: ZQuery[Any, Nothing, List[Int]] = ???
+This optimization can be achieved by field metadata from Caliban that can be referenced in your query classes.

# Introducing ZQuery

A ZQuery[R, E, A] is a purely functional description of an effectual query that may contain requests to one or more data sources. Similarly to ZIO[R, E, A], it requires an environment R, may fail with an E or succeed with an A. All requests that do not need to be performed sequentially will automatically be batched, allowing for aggressive data source specific optimizations. Requests will also automatically be deduplicated and cached.

This + allows for writing queries in a high level, compositional style, with confidence that they will automatically be + optimized. For example, consider the following query from a user services.

val getAllUserIds: ZQuery[Any, Nothing, List[Int]] = ???
 def getUserNameById(id: Int): ZQuery[Any, Nothing, String] = ???
 
 for {
@@ -71,7 +73,7 @@
   ZQuery.fromRequest(GetUserName(id))(UserDataSource)
 

To run a ZQuery, simply use ZQuery#run which will return a ZIO[R, E, A].

# ZQuery constructors and operators

There are several ways to create a ZQuery. We've seen ZQuery.fromRequest, but you can also:

  • create from a pure value with ZQuery.succeed
  • create from an effect value with ZQuery.fromZIO
  • create from multiple queries with ZQuery.collectAllPar and ZQuery.foreachPar and their sequential equivalents ZQuery.collectAll and ZQuery.foreach

If you have a ZQuery object, you can use:

  • map and mapError to modify the returned result or error
  • flatMap or zip to combine it with other ZQuery objects
  • provide and provideSome to eliminate some of the R requirements

There are several ways to run a ZQuery:

  • runCache runs the query using a given pre-populated cache. This can be useful for deterministically "replaying" a query without executing any new requests.
  • runLog runs the query and returns its result along with the cache containing a complete log of all requests executed and their results. This can be useful for logging or analysis of query execution.
  • run runs the query and returns its result.

# Using ZQuery with Caliban

To use ZQuery with Caliban, you can simply include fields of type ZQuery in your API definition.

case class Queries(
   users: ZQuery[Any, Nothing, List[User]],
-  user: UserArgs => ZQuery[Any, Nothing, User])
+  user: models.UserArgs => ZQuery[Any, Nothing, User])
 

During the query execution, Caliban will merge all the requested fields that return a ZQuery into a single ZQuery and run it, so that all the possible optimizations are applied.

The examples (opens new window) project provides 2 versions of the problem described in this article about GraphQL query optimization (opens new window):

TIP

ZQuery has a lot of operators that are similar to ZIO, such as .optional, etc. Note that just like ZIO, a field returning a ZQuery will be executed only when it is requested by the client.

TIP

When all your effects are wrapped with ZQuery.fromRequest, it is recommended to use queryExecution = QueryExecution.Batched instead of the default QueryExecution.Parallel. Doing so will provide better performance as it will avoid forking unnecessary fibers. diff --git a/vuepress/docs/docs/optimization.md b/vuepress/docs/docs/optimization.md index 73dae015fa..e404af8bde 100644 --- a/vuepress/docs/docs/optimization.md +++ b/vuepress/docs/docs/optimization.md @@ -116,7 +116,7 @@ To use `ZQuery` with Caliban, you can simply include fields of type `ZQuery` in ```scala case class Queries( users: ZQuery[Any, Nothing, List[User]], - user: UserArgs => ZQuery[Any, Nothing, User]) + user: models.UserArgs => ZQuery[Any, Nothing, User]) ``` During the query execution, Caliban will merge all the requested fields that return a `ZQuery` into a single `ZQuery` and run it, so that all the possible optimizations are applied.