Skip to content

Commit

Permalink
Merge pull request #287 from oskargotte/federation-compatibility
Browse files Browse the repository at this point in the history
Add Apollo federation spec compliance tool in CI
  • Loading branch information
yanns authored Jan 24, 2024
2 parents 8e21fc6 + 58a6fe2 commit 7a1e155
Show file tree
Hide file tree
Showing 25 changed files with 897 additions and 2 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ jobs:
run: sbt '++ ${{ matrix.scala }}' test

- name: Compress target directories
run: tar cf targets.tar example/state/target example/review/target target example/test/target example/common/target core/target project/target
run: tar cf targets.tar example/state/target example/review/target target example/test/target example/common/target example/product/target core/target project/target

- name: Upload target directories
uses: actions/upload-artifact@v3
Expand Down
43 changes: 43 additions & 0 deletions .github/workflows/compatibility.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
name: Federation Specification Compatibility Test

on:
pull_request:
branches:
- main
jobs:
compatibility:
runs-on: ubuntu-latest
steps:
- name: Checkout current branch (full)
uses: actions/checkout@v4
with:
fetch-depth: 0

- name: Setup Java (temurin@8)
uses: actions/setup-java@v3
with:
distribution: temurin
java-version: 8
cache: sbt

- name: Compatibility Test
uses: apollographql/federation-subgraph-compatibility@v2
with:
# [Required] Docker Compose file to start up the subgraph
compose: 'example/product/docker-compose.yaml'
# [Required] Path to the GraphQL schema file
schema: 'example/product/products.graphql'
# GraphQL endpoint path, defaults to '' (empty)
path: ''
# GraphQL endpoint HTTP port, defaults to 4001
port: 4001
# Turn on debug mode with extra log info
debug: false
# Github Token / PAT for submitting PR comments
token: ${{ secrets.GITHUB_TOKEN }}
# Boolean flag to indicate whether any failing test should fail the script
failOnWarning: false
# Boolean flag to indicate whether any failing required functionality test should fail the script
failOnRequired: false
# Working directory to run the action from. Should be relative from the root of the project.
workingDirectory: ''
14 changes: 13 additions & 1 deletion build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ lazy val root = (project in file("."))
description := "Federation for Sangria"
)
.settings(noPublishSettings)
.aggregate(core, exampleCommon, exampleReview, exampleState, exampleTest)
.aggregate(core, exampleCommon, exampleReview, exampleState, exampleProduct, exampleTest)

lazy val core = libraryProject("core")
.settings(
Expand Down Expand Up @@ -74,6 +74,18 @@ lazy val exampleState = exampleProject("example-state")
.dependsOn(exampleCommon)
.settings(libraryDependencies ++= serviceDependencies)

lazy val exampleProduct = exampleProject("example-product")
.dependsOn(core)
.settings(libraryDependencies ++= Seq(
Dependencies.sangriaCirce,
Dependencies.http4sEmberServer,
Dependencies.http4sCirce,
Dependencies.http4sDsl,
Dependencies.circeOptics,
Dependencies.circeGeneric,
Dependencies.circeCore
))

lazy val serviceDependencies = Seq(
Dependencies.logbackClassic,
Dependencies.circeGeneric
Expand Down
9 changes: 9 additions & 0 deletions example/product/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
FROM hseeberger/scala-sbt:17.0.2_1.6.2_3.1.1 AS build

WORKDIR /build
COPY build.sbt .
COPY project ./project/
COPY core ./core
COPY example/product/src ./example/product/src
EXPOSE 4001
CMD sbt example-product/run
17 changes: 17 additions & 0 deletions example/product/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# 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 compatibility against [Apollo Federation Subgraph Specification](https://www.apollographql.com/docs/federation/subgraph-spec/).

### Run compatibility test
Execute the following command from the repo root
```
npx @apollo/federation-subgraph-compatibility docker --compose example/product/docker-compose.yaml --schema example/product/products.graphql
```

### Printing the GraphQL schema (SQL)

```
sbt "example-product/run printSchema"
```
7 changes: 7 additions & 0 deletions example/product/docker-compose.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
services:
products:
build:
context: .
dockerfile: ./example/product/Dockerfile
ports:
- 4001:4001
83 changes: 83 additions & 0 deletions example/product/products.graphql
Original file line number Diff line number Diff line change
@@ -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!]!
}
69 changes: 69 additions & 0 deletions example/product/src/main/scala/Main.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import cats.effect.{ExitCode, IO, IOApp}
import com.comcast.ip4s.IpLiteralSyntax
import graphql.{
AppContext,
CustomDirectiveSpec,
GraphQLSchema,
InventoryGraphQLSchema,
ProductGraphQLSchema,
UserGraphQLSchema
}
import http.{GraphQLExecutor, GraphQLServer}
import io.circe.Json
import sangria.federation.v2.{CustomDirectivesDefinition, Federation, Spec}
import sangria.marshalling.InputUnmarshaller
import sangria.renderer.QueryRenderer
import sangria.schema.Schema
import service.{ProductResearchService, ProductService, UserService}

object Main extends IOApp {

override def run(args: List[String]): IO[ExitCode] = (args match {
case "printSchema" :: Nil => printSchema
case _ => runGraphQLServer
}).as(ExitCode.Success)

private def printSchema: IO[Unit] =
for {
schema <- IO(schemaAndUm).map(_._1)
_ <- IO.println(QueryRenderer.renderPretty(schema.toAst))
} yield ()

private def runGraphQLServer: IO[Unit] =
for {
ctx <- appContext
executor <- graphQLExecutor(ctx)
host = host"0.0.0.0"
port = port"4001"
_ <- IO.println(s"starting GraphQL HTTP server on http://$host:$port")
_ <- GraphQLServer.bind(executor, host, port).use(_ => IO.never)
} yield ()

private def appContext: IO[AppContext] = IO {
new AppContext {
override def productService: ProductService = ProductService.inMemory
override def productResearchService: ProductResearchService = ProductResearchService.inMemory
override def userService: UserService = UserService.inMemory
}
}

private def schemaAndUm: (Schema[AppContext, Unit], InputUnmarshaller[Json]) =
Federation.federate(
GraphQLSchema.schema,
CustomDirectivesDefinition(
Spec("https://myspecs.dev/myCustomDirective/v1.0") -> List(
CustomDirectiveSpec.CustomDirectiveDefinition)
),
sangria.marshalling.circe.CirceInputUnmarshaller,
ProductGraphQLSchema.productResolver,
ProductGraphQLSchema.deprecatedProductResolver,
ProductGraphQLSchema.productResearchResolver,
UserGraphQLSchema.userResolver,
InventoryGraphQLSchema.inventoryResolver
)

private def graphQLExecutor(context: AppContext): IO[GraphQLExecutor[AppContext]] = IO {
val (schema, um) = schemaAndUm
GraphQLExecutor(schema, context)(um)
}
}
9 changes: 9 additions & 0 deletions example/product/src/main/scala/graphql/AppContext.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package graphql

import service.{ProductResearchService, ProductService, UserService}

trait AppContext {
def productService: ProductService
def productResearchService: ProductResearchService
def userService: UserService
}
10 changes: 10 additions & 0 deletions example/product/src/main/scala/graphql/CustomDirectiveSpec.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package graphql

import sangria.ast
import sangria.schema

object CustomDirectiveSpec {
val CustomDirective: ast.Directive = ast.Directive("custom")
val CustomDirectiveDefinition: schema.Directive =
schema.Directive("custom", locations = Set(schema.DirectiveLocation.Object))
}
24 changes: 24 additions & 0 deletions example/product/src/main/scala/graphql/GraphQLSchema.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package graphql

import graphql.InventoryGraphQLSchema.InventoryType
import graphql.ProductGraphQLSchema.{deprecatedProductQueryField, productQueryField}
import graphql.UserGraphQLSchema.UserType
import sangria.schema._

object GraphQLSchema {
private val QueryType: ObjectType[AppContext, Unit] =
ObjectType(
name = "Query",
fieldsFn = () =>
fields[AppContext, Unit](
productQueryField,
deprecatedProductQueryField
)
)

val schema: Schema[AppContext, Unit] = Schema(
query = QueryType,
mutation = None,
additionalTypes = List(UserType, InventoryType)
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package graphql

import io.circe.Json
import model.{ID, Inventory}
import graphql.ProductGraphQLSchema.DeprecatedProductType
import io.circe.generic.semiauto.deriveDecoder
import sangria.federation.v2.Directives.{InterfaceObject, Key}
import sangria.federation.v2.{Decoder, EntityResolver}
import sangria.schema._

object InventoryGraphQLSchema {
val InventoryType: ObjectType[Unit, Inventory] = ObjectType(
"Inventory",
fields = fields[Unit, Inventory](
Field("id", IDType, resolve = _.value.id.value),
Field(
"deprecatedProducts",
ListType(DeprecatedProductType),
resolve = _.value.deprecatedProducts
)
)
).withDirectives(Key("id"), InterfaceObject)

case class InventoryArgs(id: ID)

val jsonDecoder: io.circe.Decoder[InventoryArgs] = deriveDecoder[InventoryArgs]
val decoder: Decoder[Json, InventoryArgs] = jsonDecoder.decodeJson

def inventoryResolver: EntityResolver[AppContext, Json] { type Arg = InventoryArgs } =
EntityResolver[AppContext, Json, Inventory, InventoryArgs](
__typeName = InventoryType.name,
(arg, ctx) => ctx.ctx.productService.inventory(arg.id)
)(decoder)
}
Loading

0 comments on commit 7a1e155

Please sign in to comment.