├── project ├── build.properties ├── plugins.sbt └── Dependencies.scala ├── example ├── .gitignore ├── state │ └── src │ │ └── main │ │ ├── scala │ │ └── state │ │ │ ├── State.scala │ │ │ ├── StateContext.scala │ │ │ ├── StateService.scala │ │ │ ├── Main.scala │ │ │ └── StateGraphQLSchema.scala │ │ └── resources │ │ └── logback.xml ├── review │ └── src │ │ └── main │ │ ├── scala │ │ └── review │ │ │ ├── Review.scala │ │ │ ├── ReviewContext.scala │ │ │ ├── State.scala │ │ │ ├── ReviewService.scala │ │ │ ├── Main.scala │ │ │ └── ReviewGraphQLSchema.scala │ │ └── resources │ │ └── logback.xml ├── product │ ├── src │ │ └── main │ │ │ └── scala │ │ │ ├── model │ │ │ ├── Inventory.scala │ │ │ ├── ID.scala │ │ │ ├── User.scala │ │ │ └── Product.scala │ │ │ ├── graphql │ │ │ ├── AppContext.scala │ │ │ ├── CustomDirectiveSpec.scala │ │ │ ├── GraphQLSchema.scala │ │ │ ├── InventoryGraphQLSchema.scala │ │ │ ├── UserGraphQLSchema.scala │ │ │ └── ProductGraphQLSchema.scala │ │ │ ├── service │ │ │ ├── UserService.scala │ │ │ ├── ProductResearchService.scala │ │ │ └── ProductService.scala │ │ │ ├── http │ │ │ ├── GraphQLError.scala │ │ │ ├── GraphQLServer.scala │ │ │ └── GraphQLExecutor.scala │ │ │ └── Main.scala │ ├── docker-compose.yaml │ ├── Dockerfile │ ├── README.md │ └── products.graphql ├── router │ ├── supergraph-local.yaml │ ├── router.yaml │ └── start.sh ├── README.md └── common │ └── src │ └── main │ └── scala │ └── common │ ├── CustomDirectives.scala │ ├── GraphQLError.scala │ ├── Server.scala │ └── GraphQL.scala ├── .github ├── release-drafter.yml └── workflows │ ├── compatibility.yaml │ ├── clean.yml │ └── ci.yml ├── .gitignore ├── .git-blame-ignore-revs ├── .sbtrc ├── core └── src │ ├── main │ ├── scala │ │ └── sangria │ │ │ └── federation │ │ │ ├── v1 │ │ │ ├── Decoder.scala │ │ │ ├── NodeObject.scala │ │ │ ├── _FieldSet.scala │ │ │ ├── _Entity.scala │ │ │ ├── _Service.scala │ │ │ ├── Query.scala │ │ │ ├── EntityResolver.scala │ │ │ ├── _Any.scala │ │ │ ├── Directives.scala │ │ │ └── Federation.scala │ │ │ ├── v2 │ │ │ ├── Decoder.scala │ │ │ ├── NodeObject.scala │ │ │ ├── _FieldSet.scala │ │ │ ├── _Entity.scala │ │ │ ├── _Service.scala │ │ │ ├── Link__Purpose.scala │ │ │ ├── Query.scala │ │ │ ├── EntityResolver.scala │ │ │ ├── _Any.scala │ │ │ ├── Link__Import.scala │ │ │ ├── Directives.scala │ │ │ └── Federation.scala │ │ │ └── tracing │ │ │ └── ApolloFederationTracing.scala │ └── protobuf │ │ └── reports.proto │ └── test │ └── scala │ └── sangria │ └── federation │ ├── FutureAwaits.scala │ ├── v1 │ ├── _AnySpec.scala │ ├── DirectivesSpec.scala │ └── FederationSpec.scala │ ├── v2 │ ├── _AnySpec.scala │ ├── Link__Import_Spec.scala │ ├── ComposeDirectiveSpec.scala │ ├── DirectivesSpec.scala │ ├── FederationSpec.scala │ └── ResolverSpec.scala │ ├── fixtures │ └── TestApp.scala │ ├── package.scala │ ├── TestSchema.scala │ └── tracing │ └── ApolloFederationTracingSpec.scala ├── .scalafmt.conf ├── HOW TO RELEASE.md ├── README.md └── LICENSE /project/build.properties: -------------------------------------------------------------------------------- 1 | sbt.version=1.11.7 2 | -------------------------------------------------------------------------------- /example/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | package-lock.json -------------------------------------------------------------------------------- /.github/release-drafter.yml: -------------------------------------------------------------------------------- 1 | template: | 2 | ## What’s Changed 3 | 4 | $CHANGES 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # folders 2 | .bsp 3 | .idea 4 | target 5 | lib 6 | classes 7 | 8 | # files 9 | *.iml 10 | -------------------------------------------------------------------------------- /.git-blame-ignore-revs: -------------------------------------------------------------------------------- 1 | # Scala Steward: Reformat with scalafmt 3.8.6 2 | e89a3ee4d8164e912febe80cd520163725c0aa55 3 | -------------------------------------------------------------------------------- /example/state/src/main/scala/state/State.scala: -------------------------------------------------------------------------------- 1 | package state 2 | 3 | case class State(id: Int, key: String, value: String) 4 | -------------------------------------------------------------------------------- /.sbtrc: -------------------------------------------------------------------------------- 1 | alias start-all=all example-review/reStart example-state/reStart 2 | alias stop-all=all example-review/reStop example-state/reStop 3 | -------------------------------------------------------------------------------- /example/review/src/main/scala/review/Review.scala: -------------------------------------------------------------------------------- 1 | package review 2 | 3 | case class Review(id: Int, key: Option[String] = None, state: State) 4 | -------------------------------------------------------------------------------- /example/product/src/main/scala/model/Inventory.scala: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | case class Inventory(id: ID, deprecatedProducts: List[DeprecatedProduct]) 4 | -------------------------------------------------------------------------------- /core/src/main/scala/sangria/federation/v1/Decoder.scala: -------------------------------------------------------------------------------- 1 | package sangria.federation.v1 2 | 3 | trait Decoder[Node, T] { 4 | 5 | def decode(node: Node): Either[Exception, T] 6 | } 7 | -------------------------------------------------------------------------------- /core/src/main/scala/sangria/federation/v2/Decoder.scala: -------------------------------------------------------------------------------- 1 | package sangria.federation.v2 2 | 3 | trait Decoder[Node, T] { 4 | 5 | def decode(node: Node): Either[Exception, T] 6 | } 7 | -------------------------------------------------------------------------------- /example/product/docker-compose.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | products: 3 | build: 4 | context: . 5 | dockerfile: ./example/product/Dockerfile 6 | ports: 7 | - 4001:4001 8 | -------------------------------------------------------------------------------- /example/state/src/main/scala/state/StateContext.scala: -------------------------------------------------------------------------------- 1 | package state 2 | 3 | import scala.concurrent.ExecutionContext 4 | 5 | case class StateContext(state: StateService, ec: ExecutionContext) 6 | -------------------------------------------------------------------------------- /example/review/src/main/scala/review/ReviewContext.scala: -------------------------------------------------------------------------------- 1 | package review 2 | 3 | import scala.concurrent.ExecutionContext 4 | 5 | case class ReviewContext(review: ReviewService, ec: ExecutionContext) 6 | -------------------------------------------------------------------------------- /example/router/supergraph-local.yaml: -------------------------------------------------------------------------------- 1 | federation_version: =2.3.2 2 | subgraphs: 3 | state: 4 | schema: 5 | subgraph_url: http://localhost:9081/api/graphql 6 | review: 7 | schema: 8 | subgraph_url: http://localhost:9082/api/graphql 9 | -------------------------------------------------------------------------------- /example/product/src/main/scala/model/ID.scala: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import io.circe.Decoder 4 | 5 | case class ID(value: String) extends AnyVal 6 | 7 | object ID { 8 | implicit val decoder: Decoder[ID] = Decoder.decodeString.map(ID.apply) 9 | } 10 | -------------------------------------------------------------------------------- /core/src/main/scala/sangria/federation/v1/NodeObject.scala: -------------------------------------------------------------------------------- 1 | package sangria.federation.v1 2 | 3 | private[federation] trait NodeObject[Node] { 4 | 5 | def __typename: Option[String] 6 | def decode[T](implicit ev: Decoder[Node, T]): Either[Exception, T] 7 | } 8 | -------------------------------------------------------------------------------- /core/src/main/scala/sangria/federation/v2/NodeObject.scala: -------------------------------------------------------------------------------- 1 | package sangria.federation.v2 2 | 3 | private[federation] trait NodeObject[Node] { 4 | 5 | def __typename: Option[String] 6 | def decode[T](implicit ev: Decoder[Node, T]): Either[Exception, T] 7 | } 8 | -------------------------------------------------------------------------------- /example/product/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM hseeberger/scala-sbt:17.0.2_1.6.2_3.1.1 AS build 2 | 3 | WORKDIR /build 4 | COPY build.sbt . 5 | COPY project ./project/ 6 | COPY core ./core 7 | COPY example/product/src ./example/product/src 8 | EXPOSE 4001 9 | CMD sbt example-product/run 10 | -------------------------------------------------------------------------------- /example/product/src/main/scala/graphql/AppContext.scala: -------------------------------------------------------------------------------- 1 | package graphql 2 | 3 | import service.{ProductResearchService, ProductService, UserService} 4 | 5 | trait AppContext { 6 | def productService: ProductService 7 | def productResearchService: ProductResearchService 8 | def userService: UserService 9 | } 10 | -------------------------------------------------------------------------------- /core/src/main/scala/sangria/federation/v1/_FieldSet.scala: -------------------------------------------------------------------------------- 1 | package sangria.federation.v1 2 | 3 | import sangria.schema.{ScalarType, StringType} 4 | 5 | case class _FieldSet(fields: String) 6 | 7 | object _FieldSet { 8 | 9 | val Type: ScalarType[String] = StringType.rename("_FieldSet").copy(description = None) 10 | } 11 | -------------------------------------------------------------------------------- /core/src/main/scala/sangria/federation/v2/_FieldSet.scala: -------------------------------------------------------------------------------- 1 | package sangria.federation.v2 2 | 3 | import sangria.schema.{ScalarType, StringType} 4 | 5 | case class _FieldSet(fields: String) 6 | 7 | object _FieldSet { 8 | 9 | val Type: ScalarType[String] = StringType.rename("_FieldSet").copy(description = None) 10 | } 11 | -------------------------------------------------------------------------------- /example/router/router.yaml: -------------------------------------------------------------------------------- 1 | cors: 2 | # allow_credentials: true 3 | allow_any_origin: true 4 | 5 | health_check: 6 | listen: 0.0.0.0:8088 7 | enabled: true 8 | 9 | homepage: 10 | enabled: false 11 | 12 | sandbox: 13 | enabled: true 14 | 15 | supergraph: 16 | listen: 0.0.0.0:4000 17 | introspection: true 18 | -------------------------------------------------------------------------------- /core/src/main/scala/sangria/federation/v1/_Entity.scala: -------------------------------------------------------------------------------- 1 | package sangria.federation.v1 2 | 3 | import sangria.schema._ 4 | 5 | case class _Entity(__typename: String) 6 | 7 | object _Entity { 8 | 9 | def apply[Ctx](types: List[ObjectType[Ctx, _]]): UnionType[Ctx] = 10 | UnionType[Ctx](name = "_Entity", types = types) 11 | } 12 | -------------------------------------------------------------------------------- /core/src/main/scala/sangria/federation/v2/_Entity.scala: -------------------------------------------------------------------------------- 1 | package sangria.federation.v2 2 | 3 | import sangria.schema._ 4 | 5 | case class _Entity(__typename: String) 6 | 7 | object _Entity { 8 | 9 | def apply[Ctx](types: List[ObjectType[Ctx, _]]): UnionType[Ctx] = 10 | UnionType[Ctx](name = "_Entity", types = types) 11 | } 12 | -------------------------------------------------------------------------------- /example/product/src/main/scala/model/User.scala: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | case class User( 4 | email: ID, 5 | name: Option[String], 6 | totalProductsCreated: Option[Int], 7 | yearsOfEmployment: Int 8 | ) { 9 | def averageProductsCreatedPerYear: Option[Int] = 10 | totalProductsCreated.map(_ / yearsOfEmployment) 11 | } 12 | -------------------------------------------------------------------------------- /core/src/main/scala/sangria/federation/v1/_Service.scala: -------------------------------------------------------------------------------- 1 | package sangria.federation.v1 2 | 3 | import sangria.schema._ 4 | 5 | case class _Service(sdl: Option[String]) 6 | 7 | object _Service { 8 | 9 | val Type: ObjectType[Unit, _Service] = ObjectType( 10 | name = "_Service", 11 | fields[Unit, _Service](Field("sdl", OptionType(StringType), resolve = _.value.sdl))) 12 | } 13 | -------------------------------------------------------------------------------- /core/src/main/scala/sangria/federation/v2/_Service.scala: -------------------------------------------------------------------------------- 1 | package sangria.federation.v2 2 | 3 | import sangria.schema._ 4 | 5 | case class _Service(sdl: Option[String]) 6 | 7 | object _Service { 8 | 9 | val Type: ObjectType[Unit, _Service] = ObjectType( 10 | name = "_Service", 11 | fields[Unit, _Service](Field("sdl", OptionType(StringType), resolve = _.value.sdl))) 12 | } 13 | -------------------------------------------------------------------------------- /example/product/src/main/scala/graphql/CustomDirectiveSpec.scala: -------------------------------------------------------------------------------- 1 | package graphql 2 | 3 | import sangria.ast 4 | import sangria.schema 5 | 6 | object CustomDirectiveSpec { 7 | val CustomDirective: ast.Directive = ast.Directive("custom") 8 | val CustomDirectiveDefinition: schema.Directive = 9 | schema.Directive("custom", locations = Set(schema.DirectiveLocation.Object)) 10 | } 11 | -------------------------------------------------------------------------------- /project/plugins.sbt: -------------------------------------------------------------------------------- 1 | addSbtPlugin("com.github.sbt" % "sbt-github-actions" % "0.28.0") 2 | addSbtPlugin("com.github.sbt" % "sbt-ci-release" % "1.11.2") 3 | addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.5.5") 4 | addSbtPlugin("io.spray" % "sbt-revolver" % "0.10.0") 5 | 6 | // https://github.com/scalapb/ScalaPB 7 | addSbtPlugin("com.thesamet" % "sbt-protoc" % "1.0.8") 8 | libraryDependencies += "com.thesamet.scalapb" %% "compilerplugin" % "0.11.20" 9 | -------------------------------------------------------------------------------- /.scalafmt.conf: -------------------------------------------------------------------------------- 1 | version = 3.10.1 2 | 3 | runner.dialect = scala213 4 | 5 | fileOverride { 6 | "glob:**/src/main/scala-3/**" { 7 | runner.dialect = scala3 8 | } 9 | } 10 | 11 | maxColumn = 100 12 | 13 | // Vertical alignment is pretty, but leads to bigger diffs 14 | align.preset = none 15 | 16 | danglingParentheses.preset = false 17 | 18 | rewrite.rules = [ 19 | AvoidInfix 20 | RedundantBraces 21 | RedundantParens 22 | AsciiSortImports 23 | PreferCurlyFors 24 | ] 25 | -------------------------------------------------------------------------------- /example/review/src/main/resources/logback.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | [%thread] %highlight(%-5level) %cyan(%logger{15}) - %msg %n 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /example/state/src/main/resources/logback.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | [%thread] %highlight(%-5level) %cyan(%logger{15}) - %msg %n 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /example/review/src/main/scala/review/State.scala: -------------------------------------------------------------------------------- 1 | package review 2 | 3 | import sangria.schema._ 4 | 5 | case class State(id: Int) 6 | 7 | object StateGraphQLSchema { 8 | 9 | import sangria.federation.v2.Directives._ 10 | 11 | val schema = 12 | ObjectType( 13 | "State", 14 | fields[Unit, State]( 15 | Field[Unit, State, Int, Int]( 16 | name = "id", 17 | fieldType = IntType, 18 | resolve = _.value.id, 19 | astDirectives = Vector(External))) 20 | ).withDirectives(Key("id"), Extends) 21 | } 22 | -------------------------------------------------------------------------------- /example/README.md: -------------------------------------------------------------------------------- 1 | ### Start/stop all the services 2 | 3 | ``` 4 | sbt start-all 5 | sbt stop-all 6 | ``` 7 | 8 | ### Start the federation router 9 | 10 | ``` 11 | cd example # if not already 12 | cd router 13 | ./start.sh 14 | ``` 15 | 16 | ### Using the exposed GraphQL endpoint 17 | 18 | The public GraphQL schema is exposed, by the federation gateway, under: http://localhost:4000/ 19 | 20 | Example of queries: 21 | ``` 22 | { 23 | reviews(ids: [34, 54]) { 24 | id 25 | key 26 | state { 27 | id 28 | key 29 | } 30 | } 31 | } 32 | ``` 33 | -------------------------------------------------------------------------------- /core/src/test/scala/sangria/federation/FutureAwaits.scala: -------------------------------------------------------------------------------- 1 | package sangria.federation 2 | 3 | import scala.concurrent.{Await, Future} 4 | import scala.concurrent.duration._ 5 | 6 | object FutureAwaits { 7 | 8 | val DefaultTimeout: Duration = 20.seconds 9 | 10 | /** Block until a Promise is redeemed. 11 | */ 12 | def await[T](future: Future[T])(implicit atMost: Duration = DefaultTimeout): T = 13 | try Await.result(future, atMost) 14 | // fill in current stack trace to be able to tell which await call failed 15 | catch { case e: Throwable => throw e.fillInStackTrace() } 16 | 17 | } 18 | -------------------------------------------------------------------------------- /example/product/src/main/scala/service/UserService.scala: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import model.{ID, User} 4 | 5 | import scala.concurrent.Future 6 | 7 | trait UserService { 8 | def user(email: ID): Future[Option[User]] 9 | } 10 | 11 | object UserService { 12 | val user: User = User( 13 | email = ID("support@apollographql.com"), 14 | name = Some("Jane Smith"), 15 | totalProductsCreated = Some(1337), 16 | yearsOfEmployment = 10 17 | ) 18 | 19 | private val users = List(user) 20 | 21 | val inMemory: UserService = (email: ID) => Future.successful(users.find(_.email == email)) 22 | } 23 | -------------------------------------------------------------------------------- /example/state/src/main/scala/state/StateService.scala: -------------------------------------------------------------------------------- 1 | package state 2 | 3 | import scala.concurrent.{ExecutionContext, Future} 4 | 5 | trait StateService { 6 | 7 | def getStates(ids: Seq[Int])(implicit ec: ExecutionContext): Future[Seq[State]] 8 | } 9 | 10 | object StateService { 11 | 12 | val inMemory: StateService = new StateService { 13 | def stateFor(id: Int): State = State( 14 | id = id, 15 | key = s"key$id", 16 | value = s"value$id" 17 | ) 18 | 19 | override def getStates(ids: Seq[Int])(implicit ec: ExecutionContext): Future[Seq[State]] = 20 | Future(ids.map(stateFor)) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /example/review/src/main/scala/review/ReviewService.scala: -------------------------------------------------------------------------------- 1 | package review 2 | 3 | import scala.concurrent.{ExecutionContext, Future} 4 | 5 | trait ReviewService { 6 | 7 | def getReviews(ids: Seq[Int])(implicit ec: ExecutionContext): Future[Seq[Review]] 8 | } 9 | 10 | object ReviewService { 11 | 12 | val inMemory: ReviewService = new ReviewService { 13 | def reviewFor(id: Int): Review = Review( 14 | id = id, 15 | key = Some(s"key$id"), 16 | State(id = 0) 17 | ) 18 | 19 | override def getReviews(ids: Seq[Int])(implicit ec: ExecutionContext): Future[Seq[Review]] = 20 | Future(ids.map(reviewFor)) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /example/router/start.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -euo pipefail 4 | 5 | DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" 6 | 7 | if [ ! -d "$DIR"/target ]; then 8 | mkdir "$DIR"/target 9 | fi 10 | 11 | pushd "$DIR"/target 12 | if [ ! -f ~/.rover/bin/rover ]; then 13 | curl -sSL https://rover.apollo.dev/nix/latest | sh 14 | fi 15 | APOLLO_TELEMETRY_DISABLED=true ~/.rover/bin/rover supergraph compose --elv2-license accept --config ../supergraph-local.yaml > supergraph-local.graphql 16 | 17 | if [ ! -f ./router ]; then 18 | curl -sSL https://router.apollo.dev/download/nix/latest | sh 19 | fi 20 | ./router --version 21 | APOLLO_TELEMETRY_DISABLED=true ./router -c ../router.yaml -s supergraph-local.graphql 22 | popd 23 | -------------------------------------------------------------------------------- /core/src/main/scala/sangria/federation/v2/Link__Purpose.scala: -------------------------------------------------------------------------------- 1 | package sangria.federation.v2 2 | 3 | import sangria.schema._ 4 | 5 | object Link__Purpose extends Enumeration { 6 | 7 | val SECURITY, EXECUTION = Value 8 | 9 | val Type: EnumType[Link__Purpose.Value] = EnumType( 10 | name = "link__Purpose", 11 | values = List( 12 | EnumValue( 13 | name = "SECURITY", 14 | description = 15 | Some("`SECURITY` features provide metadata necessary to securely resolve fields."), 16 | value = SECURITY), 17 | EnumValue( 18 | name = "EXECUTION", 19 | description = 20 | Some("`EXECUTION` features provide metadata necessary for operation execution."), 21 | value = EXECUTION) 22 | ) 23 | ) 24 | } 25 | -------------------------------------------------------------------------------- /example/product/src/main/scala/graphql/GraphQLSchema.scala: -------------------------------------------------------------------------------- 1 | package graphql 2 | 3 | import graphql.InventoryGraphQLSchema.InventoryType 4 | import graphql.ProductGraphQLSchema.{deprecatedProductQueryField, productQueryField} 5 | import graphql.UserGraphQLSchema.UserType 6 | import sangria.schema._ 7 | 8 | object GraphQLSchema { 9 | private val QueryType: ObjectType[AppContext, Unit] = 10 | ObjectType( 11 | name = "Query", 12 | fieldsFn = () => 13 | fields[AppContext, Unit]( 14 | productQueryField, 15 | deprecatedProductQueryField 16 | ) 17 | ) 18 | 19 | val schema: Schema[AppContext, Unit] = Schema( 20 | query = QueryType, 21 | mutation = None, 22 | additionalTypes = List(UserType, InventoryType) 23 | ) 24 | } 25 | -------------------------------------------------------------------------------- /example/product/src/main/scala/service/ProductResearchService.scala: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import model._ 4 | 5 | import scala.concurrent.Future 6 | 7 | trait ProductResearchService { 8 | def productResearch(studyCaseNumber: ID): Future[Option[ProductResearch]] 9 | } 10 | 11 | object ProductResearchService { 12 | private val productResearch1 = ProductResearch(CaseStudy(ID("1234"), Some("Federation Study"))) 13 | private val productResearch2 = ProductResearch(CaseStudy(ID("1235"), Some("Studio Study"))) 14 | private val productResearches: List[ProductResearch] = List(productResearch1, productResearch2) 15 | 16 | val inMemory: ProductResearchService = (studyCaseNumber: ID) => 17 | Future.successful(productResearches.find(_.study.caseNumber == studyCaseNumber)) 18 | } 19 | -------------------------------------------------------------------------------- /example/product/README.md: -------------------------------------------------------------------------------- 1 | # federated subgraph to test apollo federation spec compatibility 2 | 3 | Implementation of a federated subgraph aligned to the requirements outlined in [apollo-federation-subgraph-compatibility](https://github.com/apollographql/apollo-federation-subgraph-compatibility). 4 | 5 | The subgraph can be used to verify compatibility against [Apollo Federation Subgraph Specification](https://www.apollographql.com/docs/federation/subgraph-spec/). 6 | 7 | ### Run compatibility test 8 | Execute the following command from the repo root 9 | ``` 10 | npx @apollo/federation-subgraph-compatibility docker --compose example/product/docker-compose.yaml --schema example/product/products.graphql 11 | ``` 12 | 13 | ### Printing the GraphQL schema (SQL) 14 | 15 | ``` 16 | sbt "example-product/run printSchema" 17 | ``` 18 | -------------------------------------------------------------------------------- /HOW TO RELEASE.md: -------------------------------------------------------------------------------- 1 | # Release process 2 | 3 | How to create a new [release](../../releases). 4 | 5 | ## Releasing 6 | 7 | The release process is automated thanks to: 8 | - https://github.com/djspiewak/sbt-github-actions#integration-with-sbt-ci-release 9 | - https://github.com/olafurpg/sbt-ci-release 10 | 11 | To release, push a git tag: 12 | 13 | ``` 14 | git tag -a v0.1.0 -m "v0.1.0" 15 | git push origin v0.1.0 16 | ``` 17 | Note that the tag version MUST start with `v`. 18 | 19 | Wait for the [CI pipeline](../../actions) to release the new version. Publishing the artifacts on maven central can take time. 20 | 21 | ## Updating the release notes 22 | 23 | Open the [releases](../../releases). A draft should already be prepared. 24 | 25 | Edit the draft release to set the released version. Complete the release notes if necessary. And save it. 26 | -------------------------------------------------------------------------------- /core/src/main/scala/sangria/federation/v1/Query.scala: -------------------------------------------------------------------------------- 1 | package sangria.federation.v1 2 | 3 | import sangria.ast._ 4 | 5 | object Query { 6 | 7 | val _service: FieldDefinition = FieldDefinition( 8 | name = "_service", 9 | fieldType = NotNullType(NamedType("_Service")), 10 | arguments = Vector.empty) 11 | 12 | val _entities: FieldDefinition = 13 | FieldDefinition( 14 | name = "_entities", 15 | fieldType = NotNullType(ListType(NamedType("_Entity"))), 16 | arguments = Vector( 17 | InputValueDefinition( 18 | name = "representations", 19 | valueType = NotNullType(ListType(NotNullType(NamedType("_Any")))), 20 | defaultValue = None)) 21 | ) 22 | 23 | def queryType(fields: FieldDefinition*): ObjectTypeExtensionDefinition = 24 | ObjectTypeExtensionDefinition( 25 | name = "Query", 26 | interfaces = Vector.empty, 27 | fields = fields.toVector) 28 | } 29 | -------------------------------------------------------------------------------- /core/src/main/scala/sangria/federation/v2/Query.scala: -------------------------------------------------------------------------------- 1 | package sangria.federation.v2 2 | 3 | import sangria.ast._ 4 | 5 | object Query { 6 | 7 | val _service: FieldDefinition = FieldDefinition( 8 | name = "_service", 9 | fieldType = NotNullType(NamedType("_Service")), 10 | arguments = Vector.empty) 11 | 12 | val _entities: FieldDefinition = 13 | FieldDefinition( 14 | name = "_entities", 15 | fieldType = NotNullType(ListType(NamedType("_Entity"))), 16 | arguments = Vector( 17 | InputValueDefinition( 18 | name = "representations", 19 | valueType = NotNullType(ListType(NotNullType(NamedType("_Any")))), 20 | defaultValue = None)) 21 | ) 22 | 23 | def queryType(fields: FieldDefinition*): ObjectTypeExtensionDefinition = 24 | ObjectTypeExtensionDefinition( 25 | name = "Query", 26 | interfaces = Vector.empty, 27 | fields = fields.toVector) 28 | } 29 | -------------------------------------------------------------------------------- /core/src/main/scala/sangria/federation/v1/EntityResolver.scala: -------------------------------------------------------------------------------- 1 | package sangria.federation.v1 2 | 3 | import sangria.schema._ 4 | 5 | trait EntityResolver[Ctx, Node] { 6 | 7 | type Arg 8 | 9 | val decoder: Decoder[Node, Arg] 10 | 11 | def typename: String 12 | def resolve(arg: Arg, ctx: Context[Ctx, _]): LeafAction[Ctx, Option[_]] 13 | } 14 | 15 | object EntityResolver { 16 | 17 | def apply[Ctx, Node, Val, A]( 18 | __typeName: String, 19 | resolver: (A, Context[Ctx, Val]) => LeafAction[Ctx, Option[Val]] 20 | )(implicit ev: Decoder[Node, A]): EntityResolver[Ctx, Node] { 21 | type Arg = A 22 | } = new EntityResolver[Ctx, Node] { 23 | 24 | type Arg = A 25 | 26 | val decoder: Decoder[Node, A] = ev 27 | 28 | def typename: String = __typeName 29 | def resolve(arg: Arg, ctx: Context[Ctx, _]): LeafAction[Ctx, Option[Val]] = 30 | resolver(arg, ctx.asInstanceOf[Context[Ctx, Val]]) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /core/src/main/scala/sangria/federation/v2/EntityResolver.scala: -------------------------------------------------------------------------------- 1 | package sangria.federation.v2 2 | 3 | import sangria.schema._ 4 | 5 | trait EntityResolver[Ctx, Node] { 6 | 7 | type Arg 8 | 9 | val decoder: Decoder[Node, Arg] 10 | 11 | def typename: String 12 | def resolve(arg: Arg, ctx: Context[Ctx, _]): LeafAction[Ctx, Option[_]] 13 | } 14 | 15 | object EntityResolver { 16 | 17 | def apply[Ctx, Node, Val, A]( 18 | __typeName: String, 19 | resolver: (A, Context[Ctx, Val]) => LeafAction[Ctx, Option[Val]] 20 | )(implicit ev: Decoder[Node, A]): EntityResolver[Ctx, Node] { 21 | type Arg = A 22 | } = new EntityResolver[Ctx, Node] { 23 | 24 | type Arg = A 25 | 26 | val decoder: Decoder[Node, A] = ev 27 | 28 | def typename: String = __typeName 29 | def resolve(arg: Arg, ctx: Context[Ctx, _]): LeafAction[Ctx, Option[Val]] = 30 | resolver(arg, ctx.asInstanceOf[Context[Ctx, Val]]) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /core/src/test/scala/sangria/federation/v1/_AnySpec.scala: -------------------------------------------------------------------------------- 1 | package sangria.federation.v1 2 | 3 | import io.circe._ 4 | import io.circe.parser._ 5 | import org.scalatest.wordspec.AnyWordSpec 6 | import org.scalatest.matchers.should.Matchers 7 | import org.scalatest.EitherValues 8 | import sangria.marshalling.InputUnmarshaller 9 | import sangria.validation.Violation 10 | 11 | class _AnySpec extends AnyWordSpec with Matchers with EitherValues { 12 | 13 | implicit private val um: InputUnmarshaller[Json] = 14 | Federation.upgrade(sangria.marshalling.circe.CirceInputUnmarshaller) 15 | 16 | "_Any scalar coercion accepts an object with __typename field" in { 17 | // https://www.apollographql.com/docs/federation/v1/federation-spec#scalar-_any 18 | parseUserInput( 19 | parse("""{ "__typename": "foo", "foo": "bar" }""") 20 | .getOrElse(Json.Null)).value shouldBe a[_Any[_]] 21 | } 22 | 23 | def parseUserInput(value: Json): Either[Violation, _Any[Json]] = 24 | _Any.__type.coerceUserInput(um.getScalarValue(value)) 25 | } 26 | -------------------------------------------------------------------------------- /core/src/test/scala/sangria/federation/v2/_AnySpec.scala: -------------------------------------------------------------------------------- 1 | package sangria.federation.v2 2 | 3 | import io.circe._ 4 | import io.circe.parser._ 5 | import org.scalatest.wordspec.AnyWordSpec 6 | import org.scalatest.matchers.should.Matchers 7 | import org.scalatest.EitherValues 8 | import sangria.marshalling.InputUnmarshaller 9 | import sangria.validation.Violation 10 | 11 | class _AnySpec extends AnyWordSpec with Matchers with EitherValues { 12 | 13 | implicit private val um: InputUnmarshaller[Json] = 14 | Federation.upgrade(sangria.marshalling.circe.CirceInputUnmarshaller) 15 | 16 | "_Any scalar coercion accepts an object with __typename field" in { 17 | // https://www.apollographql.com/docs/federation/federation-spec/#scalar-_any 18 | parseUserInput( 19 | parse("""{ "__typename": "foo", "foo": "bar" }""") 20 | .getOrElse(Json.Null)).value shouldBe a[_Any[_]] 21 | } 22 | 23 | def parseUserInput(value: Json): Either[Violation, _Any[Json]] = 24 | _Any.__type.coerceUserInput(um.getScalarValue(value)) 25 | } 26 | -------------------------------------------------------------------------------- /example/common/src/main/scala/common/CustomDirectives.scala: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import sangria.schema._ 4 | import sangria.ast 5 | 6 | object CustomDirectives { 7 | object Feature { 8 | val definition: Directive = Directive( 9 | name = "feature", 10 | arguments = List( 11 | Argument("name", StringType) 12 | ), 13 | locations = Set( 14 | DirectiveLocation.FieldDefinition, 15 | DirectiveLocation.Object, 16 | DirectiveLocation.Interface, 17 | DirectiveLocation.Union, 18 | DirectiveLocation.ArgumentDefinition, 19 | DirectiveLocation.Scalar, 20 | DirectiveLocation.Enum, 21 | DirectiveLocation.EnumValue, 22 | DirectiveLocation.InputObject, 23 | DirectiveLocation.InputFieldDefinition 24 | ), 25 | repeatable = true 26 | ) 27 | 28 | def apply(name: String): ast.Directive = 29 | ast.Directive( 30 | name = "feature", 31 | arguments = Vector(ast.Argument("name", ast.StringValue(name)))) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /core/src/main/scala/sangria/federation/v2/_Any.scala: -------------------------------------------------------------------------------- 1 | package sangria.federation.v2 2 | 3 | private[federation] case class _Any[Node](__typename: String, fields: NodeObject[Node]) 4 | 5 | private[federation] object _Any { 6 | 7 | import sangria.schema.ScalarType 8 | import sangria.validation.ValueCoercionViolation 9 | 10 | case object AnyCoercionViolation extends ValueCoercionViolation("_Any value expected!!") 11 | 12 | case object TypeNameNotFound 13 | extends ValueCoercionViolation("__typename field is not defined in _Any value!!") 14 | 15 | def __type[Node]: ScalarType[_Any[Node]] = ScalarType[_Any[Node]]( 16 | name = "_Any", 17 | coerceOutput = { (_, _) => 18 | "output" 19 | }, 20 | coerceUserInput = { 21 | case n: NodeObject[Node] @unchecked => 22 | n.__typename match { 23 | case Some(__typename) => Right(_Any(__typename, n)) 24 | case None => Left(TypeNameNotFound) 25 | } 26 | case _ => Left(AnyCoercionViolation) 27 | }, 28 | coerceInput = { _ => Left(AnyCoercionViolation) } 29 | ) 30 | } 31 | -------------------------------------------------------------------------------- /core/src/main/scala/sangria/federation/v1/_Any.scala: -------------------------------------------------------------------------------- 1 | package sangria.federation.v1 2 | 3 | private[federation] case class _Any[Node](__typename: String, fields: NodeObject[Node]) 4 | 5 | private[federation] object _Any { 6 | 7 | import sangria.schema.ScalarType 8 | import sangria.validation.ValueCoercionViolation 9 | 10 | private case object AnyCoercionViolation extends ValueCoercionViolation("_Any value expected!!") 11 | 12 | private case object TypeNameNotFound 13 | extends ValueCoercionViolation("__typename field is not defined in _Any value!!") 14 | 15 | def __type[Node]: ScalarType[_Any[Node]] = ScalarType[_Any[Node]]( 16 | name = "_Any", 17 | coerceOutput = { (_, _) => 18 | "output" 19 | }, 20 | coerceUserInput = { 21 | case n: NodeObject[Node] @unchecked => 22 | n.__typename match { 23 | case Some(__typename) => Right(_Any(__typename, n)) 24 | case None => Left(TypeNameNotFound) 25 | } 26 | case _ => Left(AnyCoercionViolation) 27 | }, 28 | coerceInput = { _ => Left(AnyCoercionViolation) } 29 | ) 30 | } 31 | -------------------------------------------------------------------------------- /example/product/src/main/scala/model/Product.scala: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import io.circe.Decoder 4 | import io.circe.generic.semiauto.deriveDecoder 5 | 6 | case class ProductVariation(id: ID) 7 | object ProductVariation { 8 | implicit val jsonDecoder: Decoder[ProductVariation] = deriveDecoder[ProductVariation] 9 | } 10 | 11 | case class ProductDimension(size: Option[String], weight: Option[Double], unit: Option[String]) 12 | 13 | case class CaseStudy( 14 | caseNumber: ID, 15 | description: Option[String] 16 | ) 17 | 18 | case class ProductResearch( 19 | study: CaseStudy, 20 | outcome: Option[String] = None 21 | ) 22 | 23 | case class Product( 24 | id: ID, 25 | sku: Option[String], 26 | `package`: Option[String], 27 | variation: Option[ProductVariation], 28 | dimensions: Option[ProductDimension], 29 | createdBy: Option[User], 30 | research: List[ProductResearch], 31 | notes: Option[String] = None 32 | ) 33 | 34 | case class DeprecatedProduct( 35 | sku: String, 36 | `package`: String, 37 | reason: Option[String], 38 | createdBy: Option[User] 39 | ) 40 | -------------------------------------------------------------------------------- /project/Dependencies.scala: -------------------------------------------------------------------------------- 1 | import sbt._ 2 | 3 | object Dependencies { 4 | object V { 5 | val circe = "0.15.0-M1" 6 | val circeOptics = "0.14.1" 7 | val http4s = "1.0.0-M30" 8 | } 9 | 10 | val sangria = "org.sangria-graphql" %% "sangria" % "4.2.15" 11 | val sangriaCirce = "org.sangria-graphql" %% "sangria-circe" % "1.3.2" 12 | val logbackClassic = "ch.qos.logback" % "logback-classic" % "1.5.20" 13 | 14 | val catsEffect = "org.typelevel" %% "cats-effect" % "3.6.3" 15 | val http4sEmberServer = "org.http4s" %% "http4s-ember-server" % V.http4s 16 | val http4sCirce = "org.http4s" %% "http4s-circe" % V.http4s 17 | val http4sDsl = "org.http4s" %% "http4s-dsl" % V.http4s 18 | 19 | val circeCore = "io.circe" %% "circe-core" % V.circe 20 | val circeGeneric = "io.circe" %% "circe-generic" % V.circe 21 | val circeParser = "io.circe" %% "circe-parser" % V.circe 22 | 23 | val scalaTest = "org.scalatest" %% "scalatest" % "3.2.19" 24 | val scalapbRuntime = 25 | "com.thesamet.scalapb" %% "scalapb-runtime" % scalapb.compiler.Version.scalapbVersion % "protobuf" 26 | val weaver = "org.typelevel" %% "weaver-cats" % "0.10.1" 27 | } 28 | -------------------------------------------------------------------------------- /example/common/src/main/scala/common/GraphQLError.scala: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import io.circe.Json 4 | import sangria.execution.WithViolations 5 | import sangria.parser.SyntaxError 6 | import sangria.validation.AstNodeViolation 7 | 8 | object GraphQLError { 9 | def apply(s: String): Json = Json.obj( 10 | "errors" -> Json.arr( 11 | Json.obj("message" -> Json.fromString(s)) 12 | )) 13 | 14 | def apply(e: SyntaxError): Json = Json.obj( 15 | "errors" -> Json.arr(Json.obj( 16 | "message" -> Json.fromString(e.getMessage), 17 | "locations" -> Json.arr(Json.obj( 18 | "line" -> Json.fromInt(e.originalError.position.line), 19 | "column" -> Json.fromInt(e.originalError.position.column))) 20 | ))) 21 | 22 | def apply(e: WithViolations): Json = 23 | Json.obj("errors" -> Json.fromValues(e.violations.map { 24 | case v: AstNodeViolation => 25 | Json.obj( 26 | "message" -> Json.fromString(v.errorMessage), 27 | "locations" -> Json.fromValues(v.locations.map(loc => 28 | Json.obj("line" -> Json.fromInt(loc.line), "column" -> Json.fromInt(loc.column)))) 29 | ) 30 | case v => Json.obj("message" -> Json.fromString(v.errorMessage)) 31 | })) 32 | } 33 | -------------------------------------------------------------------------------- /example/state/src/main/scala/state/Main.scala: -------------------------------------------------------------------------------- 1 | package state 2 | 3 | import cats.effect._ 4 | import cats.implicits._ 5 | import com.comcast.ip4s._ 6 | import common.{GraphQL, Server} 7 | import io.circe.Json 8 | import org.typelevel.log4cats.Logger 9 | import org.typelevel.log4cats.slf4j.Slf4jLogger 10 | import sangria.execution.deferred.DeferredResolver 11 | import sangria.federation.v2.Federation 12 | import sangria.schema.Schema 13 | 14 | import scala.concurrent.ExecutionContext 15 | 16 | object Main extends IOApp.Simple { 17 | 18 | import StateGraphQLSchema._ 19 | 20 | private val ctx = StateContext(StateService.inMemory, ExecutionContext.global) 21 | 22 | private def graphQL[F[_]: Async]: GraphQL[F, StateContext] = { 23 | val (schema, um) = Federation.federate[StateContext, Any, Json]( 24 | Schema(StateGraphQLSchema.Query), 25 | sangria.marshalling.circe.CirceInputUnmarshaller, 26 | stateResolver) 27 | 28 | GraphQL(schema, DeferredResolver.fetchers(StateGraphQLSchema.states), ctx.pure[F])(Async[F], um) 29 | } 30 | 31 | override def run: IO[Unit] = run(Slf4jLogger.getLogger[IO]) 32 | def run(logger: Logger[IO]): IO[Unit] = 33 | Server.resource[IO, StateContext](logger, graphQL, port"9081").use(_ => IO.never) 34 | } 35 | -------------------------------------------------------------------------------- /example/product/src/main/scala/graphql/InventoryGraphQLSchema.scala: -------------------------------------------------------------------------------- 1 | package graphql 2 | 3 | import io.circe.Json 4 | import model.{ID, Inventory} 5 | import graphql.ProductGraphQLSchema.DeprecatedProductType 6 | import io.circe.generic.semiauto.deriveDecoder 7 | import sangria.federation.v2.Directives.{InterfaceObject, Key} 8 | import sangria.federation.v2.{Decoder, EntityResolver} 9 | import sangria.schema._ 10 | 11 | object InventoryGraphQLSchema { 12 | val InventoryType: ObjectType[Unit, Inventory] = ObjectType( 13 | "Inventory", 14 | fields = fields[Unit, Inventory]( 15 | Field("id", IDType, resolve = _.value.id.value), 16 | Field( 17 | "deprecatedProducts", 18 | ListType(DeprecatedProductType), 19 | resolve = _.value.deprecatedProducts 20 | ) 21 | ) 22 | ).withDirectives(Key("id"), InterfaceObject) 23 | 24 | case class InventoryArgs(id: ID) 25 | 26 | val jsonDecoder: io.circe.Decoder[InventoryArgs] = deriveDecoder[InventoryArgs] 27 | val decoder: Decoder[Json, InventoryArgs] = jsonDecoder.decodeJson 28 | 29 | def inventoryResolver: EntityResolver[AppContext, Json] { type Arg = InventoryArgs } = 30 | EntityResolver[AppContext, Json, Inventory, InventoryArgs]( 31 | __typeName = InventoryType.name, 32 | (arg, ctx) => ctx.ctx.productService.inventory(arg.id) 33 | )(decoder) 34 | } 35 | -------------------------------------------------------------------------------- /example/product/src/main/scala/http/GraphQLError.scala: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import io.circe.Json 4 | import sangria.execution.WithViolations 5 | import sangria.parser.SyntaxError 6 | import sangria.validation.AstNodeViolation 7 | 8 | object GraphQLError { 9 | def apply(s: String): Json = Json.obj( 10 | "errors" -> Json.arr( 11 | Json.obj("message" -> Json.fromString(s)) 12 | ) 13 | ) 14 | 15 | def apply(e: SyntaxError): Json = Json.obj( 16 | "errors" -> Json.arr( 17 | Json.obj( 18 | "message" -> Json.fromString(e.getMessage), 19 | "locations" -> Json.arr( 20 | Json.obj( 21 | "line" -> Json.fromInt(e.originalError.position.line), 22 | "column" -> Json.fromInt(e.originalError.position.column) 23 | ) 24 | ) 25 | ) 26 | ) 27 | ) 28 | 29 | def apply(e: WithViolations): Json = 30 | Json.obj("errors" -> Json.fromValues(e.violations.map { 31 | case v: AstNodeViolation => 32 | Json.obj( 33 | "message" -> Json.fromString(v.errorMessage), 34 | "locations" -> Json.fromValues( 35 | v.locations.map(loc => 36 | Json.obj("line" -> Json.fromInt(loc.line), "column" -> Json.fromInt(loc.column))) 37 | ) 38 | ) 39 | case v => Json.obj("message" -> Json.fromString(v.errorMessage)) 40 | })) 41 | } 42 | -------------------------------------------------------------------------------- /example/review/src/main/scala/review/Main.scala: -------------------------------------------------------------------------------- 1 | package review 2 | 3 | import cats.effect._ 4 | import cats.implicits._ 5 | import com.comcast.ip4s._ 6 | import common.{CustomDirectives, GraphQL, Server} 7 | import io.circe.Json 8 | import org.typelevel.log4cats.Logger 9 | import org.typelevel.log4cats.slf4j.Slf4jLogger 10 | import sangria.execution.deferred.DeferredResolver 11 | import sangria.federation.v2.{CustomDirectivesDefinition, Federation, Spec} 12 | import sangria.schema.Schema 13 | 14 | object Main extends IOApp.Simple { 15 | 16 | private val ctx = ReviewContext(ReviewService.inMemory, scala.concurrent.ExecutionContext.global) 17 | 18 | private def graphQL[F[_]: Async]: GraphQL[F, ReviewContext] = { 19 | val (schema, um) = Federation.federate[ReviewContext, Any, Json]( 20 | Schema(ReviewGraphQLSchema.Query), 21 | customDirectives = CustomDirectivesDefinition( 22 | Spec("https://myspecs.dev/myDirective/v1.0") -> List(CustomDirectives.Feature.definition)), 23 | sangria.marshalling.circe.CirceInputUnmarshaller 24 | ) 25 | 26 | GraphQL(schema, DeferredResolver.fetchers(ReviewGraphQLSchema.reviews), ctx.pure[F])( 27 | Async[F], 28 | um) 29 | } 30 | 31 | override def run: IO[Unit] = run(Slf4jLogger.getLogger[IO]) 32 | def run(logger: Logger[IO]): IO[Unit] = 33 | Server.resource[IO, ReviewContext](logger, graphQL, port"9082").use(_ => IO.never) 34 | } 35 | -------------------------------------------------------------------------------- /core/src/test/scala/sangria/federation/v1/DirectivesSpec.scala: -------------------------------------------------------------------------------- 1 | package sangria.federation.v1 2 | 3 | import org.scalatest.matchers.must.Matchers._ 4 | import org.scalatest.wordspec.AnyWordSpec 5 | import sangria.federation.renderLike 6 | 7 | class DirectivesSpec extends AnyWordSpec { 8 | "directives for federation v2" should { 9 | "support @key directive" in { 10 | // https://www.apollographql.com/docs/federation/federated-types/federated-directives#key 11 | Directives.Key("id") must renderLike("""@key(fields: "id")""") 12 | } 13 | 14 | "support @extends directive" in { 15 | // https://www.apollographql.com/docs/federation/federated-types/federated-directives#extends 16 | Directives.Extends must renderLike("@extends") 17 | } 18 | 19 | "support @external directive" in { 20 | // https://www.apollographql.com/docs/federation/federated-types/federated-directives#external 21 | Directives.External must renderLike("@external") 22 | } 23 | 24 | "support @provides directive" in { 25 | // https://www.apollographql.com/docs/federation/federated-types/federated-directives#provides 26 | Directives.Provides(fields = "name") must renderLike("""@provides(fields: "name")""") 27 | } 28 | 29 | "support @requires directive" in { 30 | // https://www.apollographql.com/docs/federation/federated-types/federated-directives#requires 31 | Directives.Requires(fields = "size weight") must 32 | renderLike("""@requires(fields: "size weight")""") 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /example/review/src/main/scala/review/ReviewGraphQLSchema.scala: -------------------------------------------------------------------------------- 1 | package review 2 | 3 | import common.CustomDirectives 4 | import sangria.execution.deferred.{Fetcher, HasId} 5 | import sangria.schema.{ 6 | Argument, 7 | Field, 8 | IntType, 9 | ListInputType, 10 | ListType, 11 | ObjectType, 12 | OptionType, 13 | StringType, 14 | fields 15 | } 16 | 17 | object ReviewGraphQLSchema { 18 | val ReviewType: ObjectType[Unit, Review] = 19 | ObjectType( 20 | "Review", 21 | fields[Unit, Review]( 22 | Field("id", IntType, resolve = _.value.id), 23 | Field( 24 | "key", 25 | OptionType(StringType), 26 | resolve = _.value.key, 27 | astDirectives = Vector(CustomDirectives.Feature("review-key"))), 28 | Field("state", StateGraphQLSchema.schema, resolve = _.value.state) 29 | ) 30 | ) 31 | 32 | implicit val reviewId: HasId[Review, Int] = _.id 33 | val reviews: Fetcher[ReviewContext, Review, Review, Int] = Fetcher { 34 | (ctx: ReviewContext, ids: Seq[Int]) => 35 | ctx.review.getReviews(ids)(ctx.ec) 36 | } 37 | 38 | private val ids: Argument[Seq[Int]] = 39 | Argument("ids", ListInputType(IntType)).asInstanceOf[Argument[Seq[Int]]] 40 | val Query: ObjectType[ReviewContext, Any] = ObjectType( 41 | "Query", 42 | fields[ReviewContext, Any]( 43 | Field( 44 | name = "reviews", 45 | fieldType = ListType(ReviewGraphQLSchema.ReviewType), 46 | arguments = List(ids), 47 | resolve = ctx => reviews.deferSeqOpt(ctx.arg(ids)) 48 | )) 49 | ) 50 | } 51 | -------------------------------------------------------------------------------- /.github/workflows/compatibility.yaml: -------------------------------------------------------------------------------- 1 | name: Federation Specification Compatibility Test 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | jobs: 8 | compatibility: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Checkout current branch (full) 12 | uses: actions/checkout@v4 13 | with: 14 | fetch-depth: 0 15 | 16 | - name: Setup Java (temurin@8) 17 | uses: actions/setup-java@v3 18 | with: 19 | distribution: temurin 20 | java-version: 8 21 | cache: sbt 22 | 23 | - name: Compatibility Test 24 | uses: apollographql/federation-subgraph-compatibility@v2 25 | with: 26 | # [Required] Docker Compose file to start up the subgraph 27 | compose: 'example/product/docker-compose.yaml' 28 | # [Required] Path to the GraphQL schema file 29 | schema: 'example/product/products.graphql' 30 | # GraphQL endpoint path, defaults to '' (empty) 31 | path: '' 32 | # GraphQL endpoint HTTP port, defaults to 4001 33 | port: 4001 34 | # Turn on debug mode with extra log info 35 | debug: false 36 | # Github Token / PAT for submitting PR comments 37 | token: ${{ secrets.GITHUB_TOKEN }} 38 | # Boolean flag to indicate whether any failing test should fail the script 39 | failOnWarning: false 40 | # Boolean flag to indicate whether any failing required functionality test should fail the script 41 | failOnRequired: false 42 | # Working directory to run the action from. Should be relative from the root of the project. 43 | workingDirectory: '' -------------------------------------------------------------------------------- /example/state/src/main/scala/state/StateGraphQLSchema.scala: -------------------------------------------------------------------------------- 1 | package state 2 | 3 | import io.circe.Json 4 | import io.circe.generic.semiauto.deriveDecoder 5 | import sangria.execution.deferred.{Fetcher, HasId} 6 | import sangria.federation.v2.{Decoder, Directives, EntityResolver} 7 | import sangria.schema.{ 8 | Argument, 9 | Field, 10 | IntType, 11 | ListInputType, 12 | ListType, 13 | ObjectType, 14 | StringType, 15 | fields 16 | } 17 | 18 | object StateGraphQLSchema { 19 | val StateType: ObjectType[Unit, State] = 20 | ObjectType( 21 | "State", 22 | fields[Unit, State]( 23 | Field("id", IntType, resolve = _.value.id), 24 | Field("key", StringType, resolve = _.value.key), 25 | Field("value", StringType, resolve = _.value.value)) 26 | ).withDirective(Directives.Key("id")) 27 | 28 | implicit val stateId: HasId[State, Int] = _.id 29 | val states: Fetcher[StateContext, State, State, Int] = Fetcher { 30 | (ctx: StateContext, ids: Seq[Int]) => 31 | ctx.state.getStates(ids)(ctx.ec) 32 | } 33 | 34 | private val ids: Argument[Seq[Int]] = 35 | Argument("ids", ListInputType(IntType)).asInstanceOf[Argument[Seq[Int]]] 36 | val Query: ObjectType[StateContext, Any] = ObjectType( 37 | "Query", 38 | fields[StateContext, Any]( 39 | Field( 40 | name = "states", 41 | fieldType = ListType(StateGraphQLSchema.StateType), 42 | arguments = List(ids), 43 | resolve = ctx => states.deferSeqOpt(ctx.arg(ids)))) 44 | ) 45 | 46 | // for GraphQL federation 47 | case class StateArg(id: Int) 48 | implicit val decoder: Decoder[Json, StateArg] = deriveDecoder[StateArg].decodeJson(_) 49 | val stateResolver = EntityResolver[StateContext, Json, State, StateArg]( 50 | __typeName = StateType.name, 51 | (arg, _) => states.deferOpt(arg.id)) 52 | } 53 | -------------------------------------------------------------------------------- /example/product/src/main/scala/http/GraphQLServer.scala: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import cats.data.NonEmptyList 4 | import cats.effect.{IO, ResourceIO} 5 | import com.comcast.ip4s.{Host, Port} 6 | import io.circe.Json 7 | import org.http4s.circe._ 8 | import org.http4s.dsl.Http4sDsl 9 | import org.http4s.ember.server.EmberServerBuilder 10 | import org.http4s.server.Server 11 | import org.http4s.{Header, HttpRoutes} 12 | import org.typelevel.ci.CIString 13 | import sangria.execution.Middleware 14 | import sangria.federation.tracing.ApolloFederationTracing 15 | 16 | object GraphQLServer { 17 | 18 | private object `apollo-federation-include-trace` { 19 | val name: CIString = CIString("apollo-federation-include-trace") 20 | val header: Header.Raw = Header.Raw(name, "ftv1") 21 | val oneHeader: NonEmptyList[Header.Raw] = NonEmptyList.one(header) 22 | } 23 | 24 | def routes[Ctx](executor: GraphQLExecutor[Ctx]): HttpRoutes[IO] = { 25 | val dsl = new Http4sDsl[IO] {} 26 | import dsl._ 27 | HttpRoutes.of[IO] { case req @ POST -> Root => 28 | val tracing = req.headers 29 | .get(`apollo-federation-include-trace`.name) 30 | .contains(`apollo-federation-include-trace`.oneHeader) 31 | 32 | val middleware: List[Middleware[Ctx]] = if (tracing) ApolloFederationTracing :: Nil else Nil 33 | for { 34 | json <- req.as[Json] 35 | result <- IO.fromFuture(IO.delay(executor.query(json, middleware))) 36 | httpResult <- Ok(result) 37 | } yield httpResult 38 | } 39 | } 40 | 41 | def bind[Ctx](executor: GraphQLExecutor[Ctx], host: Host, port: Port): ResourceIO[Server] = { 42 | val httpApp = routes(executor).orNotFound 43 | 44 | EmberServerBuilder 45 | .default[IO] 46 | .withHost(host) 47 | .withPort(port) 48 | .withHttpApp(httpApp) 49 | .build 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /core/src/main/scala/sangria/federation/v2/Link__Import.scala: -------------------------------------------------------------------------------- 1 | package sangria.federation.v2 2 | 3 | import sangria.ast 4 | import sangria.schema.ScalarType 5 | import sangria.validation.ValueCoercionViolation 6 | 7 | sealed trait Abstract_Link__Import 8 | object Abstract_Link__Import { 9 | def toAst(linkImport: Abstract_Link__Import): ast.Value = 10 | linkImport match { 11 | case Link__Import(value) => ast.StringValue(value) 12 | case Link__Import_Object(name, as) => 13 | new ast.ObjectValue( 14 | Vector[Option[ast.ObjectField]]( 15 | Some(ast.ObjectField("name", ast.StringValue(name))), 16 | as.map(a => ast.ObjectField("as", ast.StringValue(a))) 17 | ).flatten) 18 | } 19 | } 20 | 21 | case class Link__Import(value: String) extends Abstract_Link__Import { 22 | def as(as: String): Link__Import_Object = new Link__Import_Object(name = value, as = Some(as)) 23 | } 24 | case class Link__Import_Object(name: String, as: Option[String]) extends Abstract_Link__Import 25 | object Link__Import_Object { 26 | def apply(name: String): Link__Import_Object = new Link__Import_Object(name = name, as = None) 27 | def apply(name: String, as: String): Link__Import_Object = 28 | new Link__Import_Object(name = name, as = Some(as)) 29 | } 30 | 31 | object Link__Import { 32 | 33 | case object Link__Import_Coercion_Violation 34 | extends ValueCoercionViolation("link_Import scalar expected!!") 35 | 36 | val Type: ScalarType[Abstract_Link__Import] = ScalarType[Abstract_Link__Import]( 37 | name = "link__Import", 38 | coerceOutput = { (_, _) => "output" }, 39 | coerceUserInput = { 40 | case obj: Link__Import_Object => Right(obj) 41 | case str: String => Right(Link__Import(str)) 42 | case _ => Left(Link__Import_Coercion_Violation) 43 | }, 44 | coerceInput = { _ => Left(Link__Import_Coercion_Violation) } 45 | ) 46 | } 47 | -------------------------------------------------------------------------------- /example/product/src/main/scala/graphql/UserGraphQLSchema.scala: -------------------------------------------------------------------------------- 1 | package graphql 2 | 3 | import io.circe.Json 4 | import model.{ID, User} 5 | import sangria.federation.v2.Directives._ 6 | import sangria.federation.v2.{Decoder, EntityResolver} 7 | import sangria.schema._ 8 | 9 | object UserGraphQLSchema { 10 | // should be "extend", but according to the spec, an extend type can only exist if there is one type: 11 | // http://spec.graphql.org/draft/#sec-Object-Extensions.Type-Validation 12 | val UserType: ObjectType[Unit, User] = ObjectType( 13 | "User", 14 | fields = fields[Unit, User]( 15 | Field("email", IDType, resolve = _.value.email.value, astDirectives = Vector(External)), 16 | Field( 17 | "name", 18 | OptionType(StringType), 19 | resolve = _.value.name, 20 | astDirectives = Vector(Override("users"))), 21 | Field( 22 | "yearsOfEmployment", 23 | IntType, 24 | resolve = _.value.yearsOfEmployment, 25 | astDirectives = Vector(External)), 26 | Field( 27 | "totalProductsCreated", 28 | OptionType(IntType), 29 | resolve = _.value.totalProductsCreated, 30 | astDirectives = Vector(External) 31 | ), 32 | Field( 33 | "averageProductsCreatedPerYear", 34 | OptionType(IntType), 35 | resolve = _.value.averageProductsCreatedPerYear, 36 | astDirectives = Vector(Requires("totalProductsCreated yearsOfEmployment")) 37 | ) 38 | ) 39 | ).withDirective(Key("email")) 40 | 41 | implicit val decoder: Decoder[Json, ID] = ID.decoder.decodeJson 42 | 43 | case class UserArgs(email: ID) 44 | object UserArgs { 45 | import io.circe.generic.semiauto._ 46 | val jsonDecoder: io.circe.Decoder[UserArgs] = deriveDecoder[UserArgs] 47 | implicit val decoder: Decoder[Json, UserArgs] = jsonDecoder.decodeJson 48 | } 49 | 50 | def userResolver: EntityResolver[AppContext, Json] { type Arg = UserArgs } = 51 | EntityResolver[AppContext, Json, User, UserArgs]( 52 | __typeName = UserType.name, 53 | (arg, ctx) => ctx.ctx.userService.user(arg.email) 54 | ) 55 | } 56 | -------------------------------------------------------------------------------- /core/src/test/scala/sangria/federation/v2/Link__Import_Spec.scala: -------------------------------------------------------------------------------- 1 | package sangria.federation.v2 2 | 3 | import io.circe._ 4 | import io.circe.parser._ 5 | import org.scalatest.EitherValues 6 | import org.scalatest.matchers.should.Matchers 7 | import org.scalatest.wordspec.AnyWordSpec 8 | import sangria.marshalling.InputUnmarshaller 9 | import sangria.validation.Violation 10 | 11 | class Link__Import_Spec extends AnyWordSpec with Matchers with EitherValues { 12 | 13 | private implicit val um: InputUnmarshaller[Json] = 14 | Federation.upgrade(sangria.marshalling.circe.CirceInputUnmarshaller) 15 | 16 | "Scalar Link__Import coercion (https://specs.apollo.dev/link/v1.0/#example-import-a-string-name)" should { 17 | "accept a string" in { 18 | // https://specs.apollo.dev/link/v1.0/#example-import-a-string-name 19 | parseUserInput(Json.fromString("@link")).value shouldBe a[Link__Import] 20 | } 21 | "accept an object with a 'name' string field" in { 22 | // https://specs.apollo.dev/link/v1.0/#example-import-an-aliased-name 23 | parseUserInput( 24 | parse("""{ "name": "@example" }""").getOrElse( 25 | Json.Null)).value shouldBe Link__Import_Object(name = "@example") 26 | } 27 | "accept an object with 'name' and an optional 'as' string field" in { 28 | // https://specs.apollo.dev/link/v1.0/#example-import-an-aliased-name 29 | parseUserInput( 30 | parse("""{ "name": "@example", "as": "@eg" }""").getOrElse( 31 | Json.Null)).value shouldBe Link__Import_Object(name = "@example", as = Some("@eg")) 32 | } 33 | "raise exception if 'name' field is not a string" in { 34 | an[IllegalStateException] should be thrownBy parseUserInput( 35 | parse("""{ "name": 1 }""").getOrElse(Json.Null)).value 36 | } 37 | "raise violation error on any other scalar" in { 38 | parseUserInput(Json.fromInt(1)).left.value should be( 39 | Link__Import.Link__Import_Coercion_Violation) 40 | } 41 | } 42 | 43 | def parseUserInput(value: Json): Either[Violation, Abstract_Link__Import] = 44 | Link__Import.Type.coerceUserInput(um.getScalarValue(value)) 45 | } 46 | -------------------------------------------------------------------------------- /example/product/products.graphql: -------------------------------------------------------------------------------- 1 | extend schema 2 | @link( 3 | url: "https://specs.apollo.dev/federation/v2.3" 4 | import: [ 5 | "@composeDirective" 6 | "@extends" 7 | "@external" 8 | "@key" 9 | "@inaccessible" 10 | "@interfaceObject" 11 | "@override" 12 | "@provides" 13 | "@requires" 14 | "@shareable" 15 | "@tag" 16 | ] 17 | ) 18 | @link(url: "https://myspecs.dev/myCustomDirective/v1.0", import: ["@custom"]) 19 | @composeDirective(name: "@custom") 20 | 21 | directive @custom on OBJECT 22 | 23 | type Product 24 | @custom 25 | @key(fields: "id") 26 | @key(fields: "sku package") 27 | @key(fields: "sku variation { id }") { 28 | id: ID! 29 | sku: String 30 | package: String 31 | variation: ProductVariation 32 | dimensions: ProductDimension 33 | createdBy: User @provides(fields: "totalProductsCreated") 34 | notes: String @tag(name: "internal") 35 | research: [ProductResearch!]! 36 | } 37 | 38 | type DeprecatedProduct @key(fields: "sku package") { 39 | sku: String! 40 | package: String! 41 | reason: String 42 | createdBy: User 43 | } 44 | 45 | type ProductVariation { 46 | id: ID! 47 | } 48 | 49 | type ProductResearch @key(fields: "study { caseNumber }") { 50 | study: CaseStudy! 51 | outcome: String 52 | } 53 | 54 | type CaseStudy { 55 | caseNumber: ID! 56 | description: String 57 | } 58 | 59 | type ProductDimension @shareable { 60 | size: String 61 | weight: Float 62 | unit: String @inaccessible 63 | } 64 | 65 | extend type Query { 66 | product(id: ID!): Product 67 | deprecatedProduct(sku: String!, package: String!): DeprecatedProduct 68 | @deprecated(reason: "Use product query instead") 69 | } 70 | 71 | extend type User @key(fields: "email") { 72 | averageProductsCreatedPerYear: Int 73 | @requires(fields: "totalProductsCreated yearsOfEmployment") 74 | email: ID! @external 75 | name: String @override(from: "users") 76 | totalProductsCreated: Int @external 77 | yearsOfEmployment: Int! @external 78 | } 79 | 80 | type Inventory @interfaceObject @key(fields: "id") { 81 | id: ID! 82 | deprecatedProducts: [DeprecatedProduct!]! 83 | } 84 | -------------------------------------------------------------------------------- /example/common/src/main/scala/common/Server.scala: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import cats.data.NonEmptyList 4 | import cats.effect._ 5 | import cats.implicits._ 6 | import com.comcast.ip4s._ 7 | import io.circe.Json 8 | import org.http4s._ 9 | import org.http4s.circe._ 10 | import org.http4s.dsl._ 11 | import org.http4s.ember.server._ 12 | import org.http4s.headers.Location 13 | import org.http4s.implicits._ 14 | import org.http4s.server.{Router, Server} 15 | import org.typelevel.ci.CIString 16 | import org.typelevel.log4cats.Logger 17 | import sangria.execution.Middleware 18 | import sangria.federation.tracing.ApolloFederationTracing 19 | 20 | object Server { 21 | 22 | /** https://www.apollographql.com/docs/federation/metrics/#how-tracing-data-is-exposed-from-a-subgraph 23 | */ 24 | private val `apollo-federation-include-trace`: Header.Raw = { 25 | val name: CIString = CIString("apollo-federation-include-trace") 26 | Header.Raw(name, "ftv1") 27 | } 28 | 29 | def resource[F[_]: Async, Ctx]( 30 | logger: Logger[F], 31 | graphQL: GraphQL[F, Ctx], 32 | port: Port 33 | ): Resource[F, Server] = { 34 | 35 | object dsl extends Http4sDsl[F] 36 | import dsl._ 37 | 38 | val routes: HttpRoutes[F] = HttpRoutes.of[F] { 39 | case req @ POST -> Root / "api" / "graphql" => 40 | val tracing = req.headers 41 | .get(`apollo-federation-include-trace`.name) 42 | .contains(NonEmptyList.one(`apollo-federation-include-trace`)) 43 | val middleware: List[Middleware[Ctx]] = if (tracing) ApolloFederationTracing :: Nil else Nil 44 | req.as[Json].flatMap(json => graphQL.query(json, middleware)).flatMap { 45 | case Right(json) => Ok(json) 46 | case Left(json) => BadRequest(json) 47 | } 48 | case GET -> Root / "playground" => 49 | StaticFile 50 | .fromResource[F]("/playground.html") 51 | .getOrElseF(NotFound()) 52 | 53 | case _ => 54 | PermanentRedirect(Location(uri"/playground")) 55 | } 56 | 57 | EmberServerBuilder 58 | .default[F] 59 | .withHost(host"localhost") 60 | .withPort(port) 61 | .withHttpApp(Router("/" -> routes).orNotFound) 62 | .withLogger(logger) 63 | .build 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /core/src/test/scala/sangria/federation/fixtures/TestApp.scala: -------------------------------------------------------------------------------- 1 | package sangria.federation.fixtures 2 | 3 | import sangria.execution.deferred.HasId 4 | import sangria.federation.fixtures.TestApp.FakeDB 5 | import sangria.federation.v2.Directives.Key 6 | import sangria.schema.{Field, IntType, ListType, ObjectType, OptionType, Schema, StringType, fields} 7 | 8 | import java.util.concurrent.atomic.AtomicInteger 9 | import scala.concurrent.Future 10 | import scala.concurrent.ExecutionContext.Implicits.global 11 | 12 | class TestApp { 13 | val db: FakeDB = FakeDB() 14 | val ctx: TestApp.Context = TestApp.Context(db) 15 | } 16 | 17 | object TestApp { 18 | val missingStateId: Int = 42 19 | 20 | case class Context(db: FakeDB) 21 | 22 | case class FakeDB() { 23 | val stateDbCalled = new AtomicInteger(0) 24 | def statesByIds(ids: Seq[Int]): Future[Seq[State]] = Future { 25 | stateDbCalled.incrementAndGet() 26 | ids.filterNot(_ == missingStateId).map(id => State(id, s"mock state $id")) 27 | } 28 | 29 | val reviewDbCalled = new AtomicInteger(0) 30 | def reviewsByIds(ids: Seq[Int]): Future[Seq[Review]] = Future { 31 | reviewDbCalled.incrementAndGet() 32 | ids.map(id => Review(id, s"mock review $id")) 33 | } 34 | } 35 | 36 | case class State(id: Int, value: String) 37 | object State { 38 | implicit val hasId: HasId[State, Int] = _.id 39 | } 40 | 41 | val StateType: ObjectType[Context, State] = ObjectType( 42 | "State", 43 | fields[Context, State]( 44 | Field("id", IntType, resolve = _.value.id), 45 | Field("value", OptionType(StringType), resolve = _.value.value))).withDirective(Key("id")) 46 | 47 | case class Review(id: Int, value: String) 48 | object Review { 49 | implicit val hasId: HasId[Review, Int] = _.id 50 | } 51 | 52 | // Review GraphQL Model 53 | val ReviewType: ObjectType[Unit, Review] = ObjectType( 54 | "Review", 55 | fields[Unit, Review]( 56 | Field("id", IntType, resolve = _.value.id), 57 | Field("value", OptionType(StringType), resolve = _.value.value))).withDirective(Key("id")) 58 | 59 | val Query: ObjectType[Context, Any] = ObjectType( 60 | "Query", 61 | fields[Context, Any]( 62 | Field(name = "states", fieldType = ListType(StateType), resolve = _ => Nil), 63 | Field(name = "reviews", fieldType = ListType(ReviewType), resolve = _ => Nil) 64 | ) 65 | ) 66 | 67 | val schema: Schema[Context, Any] = Schema(Query) 68 | 69 | } 70 | -------------------------------------------------------------------------------- /example/product/src/main/scala/http/GraphQLExecutor.scala: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import io.circe.{Json, JsonObject} 4 | import sangria.ast 5 | import sangria.execution.{Executor, Middleware} 6 | import sangria.marshalling.InputUnmarshaller 7 | import sangria.marshalling.circe.CirceResultMarshaller 8 | import sangria.parser.{QueryParser, SyntaxError} 9 | import sangria.schema.Schema 10 | 11 | import scala.concurrent.ExecutionContext.Implicits._ 12 | import scala.concurrent.Future 13 | import scala.util.{Failure, Success} 14 | 15 | trait GraphQLExecutor[Ctx] { 16 | def query(request: Json, middleware: List[Middleware[Ctx]]): Future[Json] 17 | } 18 | 19 | object GraphQLExecutor { 20 | 21 | def apply[Ctx](schema: Schema[Ctx, Unit], context: Ctx)(implicit 22 | um: InputUnmarshaller[Json]): GraphQLExecutor[Ctx] = 23 | new GraphQLExecutor[Ctx] { 24 | override def query(request: Json, middleware: List[Middleware[Ctx]]): Future[Json] = 25 | request.hcursor.downField("query").as[String] match { 26 | case Right(qs) => 27 | val operationName = 28 | request.hcursor.downField("operationName").as[Option[String]].getOrElse(None) 29 | val variables = 30 | request.hcursor.downField("variables").as[Json].getOrElse(Json.obj()) 31 | query(qs, operationName, variables, middleware) 32 | case Left(_) => 33 | Future.successful(GraphQLError("No 'query' property was present in the request.")) 34 | } 35 | 36 | private def query( 37 | query: String, 38 | operationName: Option[String], 39 | variables: Json, 40 | middleware: List[Middleware[Ctx]] 41 | ): Future[Json] = 42 | QueryParser.parse(query) match { 43 | case Success(ast) => exec(ast, operationName, variables, middleware) 44 | case Failure(e: SyntaxError) => Future.successful(http.GraphQLError(e)) 45 | case Failure(e) => Future.failed(e) 46 | } 47 | 48 | private def exec( 49 | query: ast.Document, 50 | operationName: Option[String], 51 | variables: Json, 52 | middleware: List[Middleware[Ctx]] 53 | ): Future[Json] = 54 | Executor.execute( 55 | schema = schema, 56 | queryAst = query, 57 | userContext = context, 58 | variables = variables, 59 | operationName = operationName, 60 | middleware = middleware 61 | ) 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /example/product/src/main/scala/Main.scala: -------------------------------------------------------------------------------- 1 | import cats.effect.{ExitCode, IO, IOApp} 2 | import com.comcast.ip4s._ 3 | import graphql.{ 4 | AppContext, 5 | CustomDirectiveSpec, 6 | GraphQLSchema, 7 | InventoryGraphQLSchema, 8 | ProductGraphQLSchema, 9 | UserGraphQLSchema 10 | } 11 | import http.{GraphQLExecutor, GraphQLServer} 12 | import io.circe.Json 13 | import sangria.federation.v2.{CustomDirectivesDefinition, Federation, Spec} 14 | import sangria.marshalling.InputUnmarshaller 15 | import sangria.renderer.QueryRenderer 16 | import sangria.schema.Schema 17 | import service.{ProductResearchService, ProductService, UserService} 18 | 19 | object Main extends IOApp { 20 | 21 | override def run(args: List[String]): IO[ExitCode] = (args match { 22 | case "printSchema" :: Nil => printSchema 23 | case _ => runGraphQLServer 24 | }).as(ExitCode.Success) 25 | 26 | private def printSchema: IO[Unit] = 27 | for { 28 | schema <- IO(schemaAndUm).map(_._1) 29 | _ <- IO.println(QueryRenderer.renderPretty(schema.toAst)) 30 | } yield () 31 | 32 | private def runGraphQLServer: IO[Unit] = 33 | for { 34 | ctx <- appContext 35 | executor <- graphQLExecutor(ctx) 36 | host = host"0.0.0.0" 37 | port = port"4001" 38 | _ <- IO.println(s"starting GraphQL HTTP server on http://$host:$port") 39 | _ <- GraphQLServer.bind(executor, host, port).use(_ => IO.never) 40 | } yield () 41 | 42 | private def appContext: IO[AppContext] = IO { 43 | new AppContext { 44 | override def productService: ProductService = ProductService.inMemory 45 | override def productResearchService: ProductResearchService = ProductResearchService.inMemory 46 | override def userService: UserService = UserService.inMemory 47 | } 48 | } 49 | 50 | private def schemaAndUm: (Schema[AppContext, Unit], InputUnmarshaller[Json]) = 51 | Federation.federate( 52 | GraphQLSchema.schema, 53 | CustomDirectivesDefinition( 54 | Spec("https://myspecs.dev/myCustomDirective/v1.0") -> List( 55 | CustomDirectiveSpec.CustomDirectiveDefinition) 56 | ), 57 | sangria.marshalling.circe.CirceInputUnmarshaller, 58 | ProductGraphQLSchema.productResolver, 59 | ProductGraphQLSchema.deprecatedProductResolver, 60 | ProductGraphQLSchema.productResearchResolver, 61 | UserGraphQLSchema.userResolver, 62 | InventoryGraphQLSchema.inventoryResolver 63 | ) 64 | 65 | private def graphQLExecutor(context: AppContext): IO[GraphQLExecutor[AppContext]] = IO { 66 | val (schema, um) = schemaAndUm 67 | GraphQLExecutor(schema, context)(um) 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /.github/workflows/clean.yml: -------------------------------------------------------------------------------- 1 | # This file was automatically generated by sbt-github-actions using the 2 | # githubWorkflowGenerate task. You should add and commit this file to 3 | # your git repository. It goes without saying that you shouldn't edit 4 | # this file by hand! Instead, if you wish to make changes, you should 5 | # change your sbt build configuration to revise the workflow description 6 | # to meet your needs, then regenerate this file. 7 | 8 | name: Clean 9 | 10 | on: push 11 | 12 | permissions: 13 | actions: write 14 | 15 | jobs: 16 | delete-artifacts: 17 | name: Delete Artifacts 18 | runs-on: ubuntu-latest 19 | env: 20 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 21 | steps: 22 | - name: Delete artifacts 23 | shell: bash {0} 24 | run: | 25 | # Customize those three lines with your repository and credentials: 26 | REPO=${GITHUB_API_URL}/repos/${{ github.repository }} 27 | 28 | # A shortcut to call GitHub API. 29 | ghapi() { curl --silent --location --user _:$GITHUB_TOKEN "$@"; } 30 | 31 | # A temporary file which receives HTTP response headers. 32 | TMPFILE=$(mktemp) 33 | 34 | # An associative array, key: artifact name, value: number of artifacts of that name. 35 | declare -A ARTCOUNT 36 | 37 | # Process all artifacts on this repository, loop on returned "pages". 38 | URL=$REPO/actions/artifacts 39 | while [[ -n "$URL" ]]; do 40 | 41 | # Get current page, get response headers in a temporary file. 42 | JSON=$(ghapi --dump-header $TMPFILE "$URL") 43 | 44 | # Get URL of next page. Will be empty if we are at the last page. 45 | URL=$(grep '^Link:' "$TMPFILE" | tr ',' '\n' | grep 'rel="next"' | head -1 | sed -e 's/.*.*//') 46 | rm -f $TMPFILE 47 | 48 | # Number of artifacts on this page: 49 | COUNT=$(( $(jq <<<$JSON -r '.artifacts | length') )) 50 | 51 | # Loop on all artifacts on this page. 52 | for ((i=0; $i < $COUNT; i++)); do 53 | 54 | # Get name of artifact and count instances of this name. 55 | name=$(jq <<<$JSON -r ".artifacts[$i].name?") 56 | ARTCOUNT[$name]=$(( $(( ${ARTCOUNT[$name]} )) + 1)) 57 | 58 | id=$(jq <<<$JSON -r ".artifacts[$i].id?") 59 | size=$(( $(jq <<<$JSON -r ".artifacts[$i].size_in_bytes?") )) 60 | printf "Deleting '%s' #%d, %'d bytes\n" $name ${ARTCOUNT[$name]} $size 61 | ghapi -X DELETE $REPO/actions/artifacts/$id 62 | done 63 | done 64 | -------------------------------------------------------------------------------- /core/src/test/scala/sangria/federation/package.scala: -------------------------------------------------------------------------------- 1 | package sangria 2 | 3 | import org.scalatest.matchers.must.Matchers.be 4 | import org.scalatest.matchers.{MatchResult, Matcher} 5 | import sangria.renderer.QueryRenderer 6 | import sangria.schema.Schema 7 | import sangria.schema.SchemaChange.AbstractChange 8 | 9 | package object federation { 10 | 11 | val spec: String = "https://specs.apollo.dev/federation/v2.3" 12 | 13 | def beCompatibleWith(expectedSchema: Schema[_, _]): Matcher[Schema[_, _]] = 14 | Matcher { (schema: Schema[_, _]) => 15 | val changes = schema.compare(expectedSchema) 16 | 17 | MatchResult( 18 | changes.collect { case _: AbstractChange => true }.isEmpty, 19 | s"Schemas have following changes:\n ${changes.mkString("\n ")}", 20 | s"Schemas should be different but no changes can be found" 21 | ) 22 | } 23 | 24 | private def pretty(v: Vector[_]): String = v.mkString("\n - ", "\n - ", "") 25 | 26 | def importFederationDirective(name: String): Matcher[Schema[_, _]] = 27 | Matcher { (schema: Schema[_, _]) => 28 | val links = schema.astDirectives.filter(d => d.name == "link") 29 | val link = links.find(d => 30 | d.arguments.exists(arg => arg.name == "url" && arg.value == ast.StringValue(spec))) 31 | 32 | val error = link match { 33 | case None => Some(s"""no link with url "$spec" found under all links:${pretty(links)}""") 34 | case Some(federationLink) => 35 | federationLink.arguments.find(_.name == "import") match { 36 | case None => Some(s"""the "$spec" link does not have any "imports""") 37 | case Some(importValue) => 38 | importValue.value match { 39 | case lv: ast.ListValue => 40 | lv.values.find { 41 | case sv: ast.StringValue if sv.value == name => true 42 | case _ => false 43 | } match { 44 | case None => 45 | Some( 46 | s"""the "import" value of the "$spec" link does not contain "$name" but contain:${pretty( 47 | lv.values)}""") 48 | case _ => None 49 | } 50 | case other => 51 | Some( 52 | s"""the "$spec" link does not have an array value for "imports" but "${other.getClass}"""") 53 | } 54 | } 55 | } 56 | MatchResult( 57 | error.isEmpty, 58 | error.getOrElse(""), 59 | error.getOrElse("") 60 | ) 61 | } 62 | 63 | def renderLike(expected: String): Matcher[ast.Directive] = 64 | Matcher { (directive: ast.Directive) => 65 | val rendered = QueryRenderer.renderPretty(directive) 66 | be(expected)(rendered) 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /example/product/src/main/scala/service/ProductService.scala: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import model._ 4 | import service.UserService.user 5 | 6 | import scala.concurrent.Future 7 | 8 | trait ProductService { 9 | def product(id: ID): Future[Option[Product]] 10 | def bySkuAndPackage(sku: String, `package`: String): Future[Option[Product]] 11 | def bySkuAndProductVariantionId(sku: String, variation: ProductVariation): Future[Option[Product]] 12 | def deprecatedProduct(sku: String, `package`: String): Future[Option[DeprecatedProduct]] 13 | def inventory(id: ID): Future[Option[Inventory]] 14 | } 15 | 16 | object ProductService { 17 | private val dimension = ProductDimension(Some("small"), Some(1.0f), Some("kg")) 18 | 19 | private val deprecatedProduct = DeprecatedProduct( 20 | sku = "apollo-federation-v1", 21 | `package` = "@apollo/federation-v1", 22 | reason = Some("Migrate to Federation V2"), 23 | createdBy = Some(user) 24 | ) 25 | 26 | private val product1 = Product( 27 | id = ID("apollo-federation"), 28 | sku = Some("federation"), 29 | `package` = Some("@apollo/federation"), 30 | variation = Some(ProductVariation(ID("OSS"))), 31 | dimensions = Some(dimension), 32 | research = List(ProductResearch(CaseStudy(ID("1234"), Some("Federation Study")))), 33 | createdBy = Some(user) 34 | ) 35 | 36 | private val product2 = Product( 37 | id = ID("apollo-studio"), 38 | sku = Some("studio"), 39 | `package` = Some(""), 40 | variation = Some(ProductVariation(ID("platform"))), 41 | dimensions = Some(dimension), 42 | research = List(ProductResearch(CaseStudy(ID("1235"), Some("Studio Study")))), 43 | createdBy = Some(user) 44 | ) 45 | 46 | private val products: List[Product] = List(product1, product2) 47 | private val deprecatedProducts: List[DeprecatedProduct] = List(deprecatedProduct) 48 | 49 | private val inventoryList: List[Inventory] = List( 50 | Inventory( 51 | id = ID("apollo-oss"), 52 | deprecatedProducts = deprecatedProducts 53 | ) 54 | ) 55 | 56 | val inMemory: ProductService = new ProductService { 57 | override def product(id: ID): Future[Option[Product]] = 58 | Future.successful(products.find(_.id == id)) 59 | 60 | override def bySkuAndPackage(sku: String, `package`: String): Future[Option[Product]] = 61 | Future.successful(products.find { p => 62 | p.sku.contains(sku) && p.`package`.contains(`package`) 63 | }) 64 | 65 | override def bySkuAndProductVariantionId( 66 | sku: String, 67 | variation: ProductVariation): Future[Option[Product]] = 68 | Future.successful(products.find { p => 69 | p.sku.contains(sku) && 70 | p.variation.exists(_.id == variation.id) 71 | }) 72 | 73 | override def deprecatedProduct( 74 | sku: String, 75 | `package`: String): Future[Option[DeprecatedProduct]] = 76 | Future.successful(deprecatedProducts.find(p => p.sku == sku && p.`package` == `package`)) 77 | 78 | def inventory(id: ID): Future[Option[Inventory]] = 79 | Future.successful(inventoryList.find(_.id == id)) 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /core/src/test/scala/sangria/federation/TestSchema.scala: -------------------------------------------------------------------------------- 1 | package sangria.federation 2 | 3 | import sangria.schema._ 4 | 5 | import scala.concurrent.ExecutionContext.Implicits.global 6 | import scala.concurrent.Future 7 | 8 | object TestSchema { 9 | trait Named { 10 | def name: Option[String] 11 | } 12 | 13 | case class Dog(name: Option[String], barks: Option[Boolean]) extends Named 14 | 15 | case class Cat(name: Option[String], meows: Option[Boolean]) extends Named 16 | 17 | case class Person( 18 | name: Option[String], 19 | pets: Option[List[Option[Any]]], 20 | friends: Option[List[Option[Named]]]) 21 | extends Named 22 | 23 | val NamedType: InterfaceType[Unit, Named] = InterfaceType( 24 | "Named", 25 | fields[Unit, Named](Field("name", OptionType(StringType), resolve = _.value.name))) 26 | 27 | val DogType: ObjectType[Unit, Dog] = ObjectType( 28 | "Dog", 29 | interfaces[Unit, Dog](NamedType), 30 | fields[Unit, Dog]( 31 | Field("name", OptionType(StringType), resolve = _.value.name), 32 | Field("barks", OptionType(BooleanType), resolve = _.value.barks)) 33 | ) 34 | 35 | val CatType: ObjectType[Unit, Cat] = ObjectType( 36 | "Cat", 37 | interfaces[Unit, Cat](NamedType), 38 | fields[Unit, Cat]( 39 | Field( 40 | "name", 41 | OptionType(StringType), 42 | resolve = c => 43 | Future { 44 | Thread.sleep((math.random() * 10).toLong) 45 | c.value.name 46 | } 47 | ), 48 | Field("meows", OptionType(BooleanType), resolve = _.value.meows) 49 | ) 50 | ) 51 | 52 | val PetType: UnionType[Unit] = UnionType[Unit]("Pet", types = DogType :: CatType :: Nil) 53 | 54 | val LimitArg: Argument[Int] = Argument("limit", OptionInputType(IntType), 10) 55 | 56 | val PersonType: ObjectType[Unit, Person] = ObjectType( 57 | "Person", 58 | interfaces[Unit, Person](NamedType), 59 | fields[Unit, Person]( 60 | Field( 61 | "pets", 62 | OptionType(ListType(OptionType(PetType))), 63 | arguments = LimitArg :: Nil, 64 | resolve = c => c.withArgs(LimitArg)(limit => c.value.pets.map(_.take(limit)))), 65 | Field("favouritePet", PetType, resolve = _.value.pets.flatMap(_.headOption.flatten).get), 66 | Field( 67 | "favouritePetList", 68 | ListType(PetType), 69 | resolve = _.value.pets.getOrElse(Nil).flatten.toSeq), 70 | Field( 71 | "favouritePetOpt", 72 | OptionType(PetType), 73 | resolve = _.value.pets.flatMap(_.headOption.flatten)), 74 | Field("friends", OptionType(ListType(OptionType(NamedType))), resolve = _.value.friends) 75 | ) 76 | ) 77 | 78 | val TestSchema: Schema[Unit, Person] = Schema(PersonType) 79 | 80 | val garfield: Cat = Cat(Some("Garfield"), Some(false)) 81 | val odie: Dog = Dog(Some("Odie"), Some(true)) 82 | val liz: Person = Person(Some("Liz"), None, None) 83 | val bob: Person = Person( 84 | Some("Bob"), 85 | Some(Iterator.continually(Some(garfield)).take(20).toList :+ Some(odie)), 86 | Some(List(Some(liz), Some(odie)))) 87 | 88 | val schema: Schema[Unit, Person] = Schema(PersonType) 89 | } 90 | -------------------------------------------------------------------------------- /core/src/main/scala/sangria/federation/v1/Directives.scala: -------------------------------------------------------------------------------- 1 | package sangria.federation.v1 2 | 3 | import sangria.ast 4 | import sangria.schema.{Argument, Directive, DirectiveLocation} 5 | 6 | object Directives { 7 | 8 | /** [@key](https://www.apollographql.com/docs/federation/federated-types/federated-directives#key) 9 | * directive 10 | */ 11 | object Key { 12 | val definition: Directive = Directive( 13 | name = "key", 14 | arguments = Argument("fields", _FieldSet.Type) :: Nil, 15 | locations = Set(DirectiveLocation.Object, DirectiveLocation.Interface), 16 | repeatable = true 17 | ) 18 | 19 | def apply(fields: String): ast.Directive = 20 | ast.Directive( 21 | name = "key", 22 | arguments = Vector(ast.Argument("fields", ast.StringValue(fields)))) 23 | } 24 | 25 | /** [@extends](https://www.apollographql.com/docs/federation/federated-types/federated-directives#extends) 26 | * directive definition 27 | */ 28 | val ExtendsDefinition: Directive = Directive( 29 | name = "extends", 30 | locations = Set(DirectiveLocation.Object, DirectiveLocation.Interface)) 31 | 32 | /** [@extends](https://www.apollographql.com/docs/federation/federated-types/federated-directives#extends) 33 | * directive 34 | */ 35 | val Extends: ast.Directive = ast.Directive("extends") 36 | 37 | /** [@requires](https://www.apollographql.com/docs/federation/federated-types/federated-directives#requires) 38 | * directive 39 | */ 40 | object Requires { 41 | val definition: Directive = Directive( 42 | name = "requires", 43 | arguments = Argument("fields", _FieldSet.Type) :: Nil, 44 | locations = Set(DirectiveLocation.FieldDefinition)) 45 | 46 | def apply(fields: String): ast.Directive = 47 | ast.Directive( 48 | name = "requires", 49 | arguments = Vector(ast.Argument("fields", ast.StringValue(fields)))) 50 | } 51 | 52 | /** [@external](https://www.apollographql.com/docs/federation/federated-types/federated-directives#external) 53 | * directive definition 54 | */ 55 | val ExternalDefinition: Directive = 56 | Directive(name = "external", locations = Set(DirectiveLocation.FieldDefinition)) 57 | 58 | /** [@external](https://www.apollographql.com/docs/federation/federated-types/federated-directives#external) 59 | * directive 60 | */ 61 | val External: ast.Directive = ast.Directive("external") 62 | 63 | /** [@provides](https://www.apollographql.com/docs/federation/federated-types/federated-directives#provides) 64 | * directive 65 | */ 66 | object Provides { 67 | val definition: Directive = Directive( 68 | name = "provides", 69 | arguments = Argument("fields", _FieldSet.Type) :: Nil, 70 | locations = Set(DirectiveLocation.FieldDefinition)) 71 | 72 | def apply(fields: String): ast.Directive = 73 | ast.Directive( 74 | name = "provides", 75 | arguments = Vector(ast.Argument(name = "fields", value = ast.StringValue(fields)))) 76 | } 77 | 78 | val definitions: List[Directive] = List( 79 | Key.definition, 80 | ExternalDefinition, 81 | ExtendsDefinition, 82 | Requires.definition, 83 | Provides.definition 84 | ) 85 | } 86 | -------------------------------------------------------------------------------- /example/common/src/main/scala/common/GraphQL.scala: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import cats.effect._ 4 | import cats.implicits._ 5 | import io.circe._ 6 | import sangria.ast 7 | import sangria.execution._ 8 | import sangria.execution.deferred.DeferredResolver 9 | import sangria.marshalling.InputUnmarshaller 10 | import sangria.marshalling.circe.CirceResultMarshaller 11 | import sangria.parser.{QueryParser, SyntaxError} 12 | import sangria.schema.Schema 13 | 14 | import scala.concurrent.ExecutionContext 15 | import scala.util.{Failure, Success} 16 | 17 | trait GraphQL[F[_], Ctx] { 18 | 19 | def query(request: Json, middleware: List[Middleware[Ctx]]): F[Either[Json, Json]] 20 | 21 | def query( 22 | query: String, 23 | operationName: Option[String], 24 | variables: Json, 25 | middleware: List[Middleware[Ctx]] 26 | ): F[Either[Json, Json]] 27 | } 28 | 29 | object GraphQL { 30 | 31 | def apply[F[_], Ctx]( 32 | schema: Schema[Ctx, Any], 33 | deferredResolver: DeferredResolver[Ctx], 34 | userContext: F[Ctx] 35 | )(implicit F: Async[F], um: InputUnmarshaller[Json]): GraphQL[F, Ctx] = 36 | new GraphQL[F, Ctx] { 37 | 38 | private def fail(j: Json): F[Either[Json, Json]] = 39 | F.pure(j.asLeft) 40 | 41 | def exec( 42 | schema: Schema[Ctx, Any], 43 | userContext: F[Ctx], 44 | query: ast.Document, 45 | operationName: Option[String], 46 | variables: Json, 47 | middleware: List[Middleware[Ctx]]): F[Either[Json, Json]] = 48 | for { 49 | ctx <- userContext 50 | executionContext <- Async[F].executionContext 51 | execution <- F.attempt(F.fromFuture(F.delay { 52 | implicit val ec: ExecutionContext = executionContext 53 | Executor 54 | .execute( 55 | schema = schema, 56 | queryAst = query, 57 | userContext = ctx, 58 | variables = variables, 59 | operationName = operationName, 60 | middleware = middleware, 61 | deferredResolver = deferredResolver 62 | ) 63 | })) 64 | result <- execution match { 65 | case Right(json) => F.pure(json.asRight) 66 | case Left(err: WithViolations) => fail(GraphQLError(err)) 67 | case Left(err) => F.raiseError(err) 68 | } 69 | } yield result 70 | 71 | override def query(request: Json, middleware: List[Middleware[Ctx]]): F[Either[Json, Json]] = 72 | request.hcursor.downField("query").as[String] match { 73 | case Right(qs) => 74 | val operationName = 75 | request.hcursor.downField("operationName").as[Option[String]].getOrElse(None) 76 | val variables = 77 | request.hcursor.downField("variables").as[Json].getOrElse(Json.obj()) 78 | query(qs, operationName, variables, middleware) 79 | case Left(_) => fail(GraphQLError("No 'query' property was present in the request.")) 80 | } 81 | 82 | override def query( 83 | query: String, 84 | operationName: Option[String], 85 | variables: Json, 86 | middleware: List[Middleware[Ctx]]): F[Either[Json, Json]] = 87 | QueryParser.parse(query) match { 88 | case Success(ast) => exec(schema, userContext, ast, operationName, variables, middleware) 89 | case Failure(e: SyntaxError) => fail(GraphQLError(e)) 90 | case Failure(e) => F.raiseError(e) 91 | } 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /core/src/test/scala/sangria/federation/tracing/ApolloFederationTracingSpec.scala: -------------------------------------------------------------------------------- 1 | package sangria.federation.tracing 2 | 3 | import com.google.protobuf.timestamp.Timestamp 4 | import io.circe.{Json, JsonObject, parser} 5 | import io.circe.parser.parse 6 | import org.scalatest.OptionValues 7 | import org.scalatest.matchers.should.Matchers 8 | import org.scalatest.wordspec.AsyncWordSpec 9 | import reports.Trace 10 | import sangria.execution.Executor 11 | import sangria.macros._ 12 | import sangria.marshalling.ScalaInput 13 | import sangria.marshalling.circe._ 14 | 15 | import java.util.Base64 16 | 17 | class ApolloFederationTracingSpec extends AsyncWordSpec with Matchers with OptionValues { 18 | import sangria.federation.TestSchema._ 19 | 20 | private val mainQuery = 21 | gql""" 22 | query Foo { 23 | friends { 24 | ...Name 25 | ...Name2 26 | } 27 | } 28 | 29 | query Test($$limit: Int!) { 30 | __typename 31 | name 32 | ...Name1 33 | pets(limit: $$limit) { 34 | ... on Cat { 35 | name 36 | meows 37 | ...Name 38 | } 39 | ... on Dog { 40 | ...Name1 41 | ...Name1 42 | foo: name 43 | barks 44 | } 45 | } 46 | } 47 | 48 | fragment Name on Named { 49 | name 50 | ...Name1 51 | } 52 | 53 | fragment Name1 on Named { 54 | ... on Person { 55 | name 56 | } 57 | } 58 | 59 | fragment Name2 on Named { 60 | name 61 | } 62 | """ 63 | 64 | "ApolloFederationTracing" should { 65 | "add tracing extension" in { 66 | val vars = ScalaInput.scalaInput(Map("limit" -> 4)) 67 | 68 | Executor 69 | .execute( 70 | schema, 71 | mainQuery, 72 | root = bob, 73 | operationName = Some("Test"), 74 | variables = vars, 75 | middleware = ApolloFederationTracing :: Nil) 76 | .map { (result: Json) => 77 | result.hcursor.get[Json]("data") should be(parse(""" 78 | { 79 | "__typename": "Person", 80 | "name": "Bob", 81 | "pets": [ 82 | { 83 | "name": "Garfield", 84 | "meows": false 85 | }, 86 | { 87 | "name": "Garfield", 88 | "meows": false 89 | }, 90 | { 91 | "name": "Garfield", 92 | "meows": false 93 | }, 94 | { 95 | "name": "Garfield", 96 | "meows": false 97 | } 98 | ] 99 | } 100 | """)) 101 | 102 | val ftv1Trace = 103 | parseTrace(result.hcursor.downField("extensions").get[String]("ftv1").toOption.value) 104 | 105 | // TODO: add root node and fields 106 | removeTime(ftv1Trace) should be( 107 | Trace( 108 | startTime = Some(startTimestamp), 109 | endTime = Some(endTimestamp), 110 | durationNs = 1 111 | )) 112 | } 113 | } 114 | } 115 | 116 | private def parseTrace(trace: String): Trace = Trace.parseFrom(Base64.getDecoder.decode(trace)) 117 | 118 | private val startTimestamp: Timestamp = Timestamp(1, 0) 119 | private val endTimestamp: Timestamp = Timestamp(1, 1) 120 | 121 | private def removeTime(trace: Trace): Trace = 122 | trace.update( 123 | _.startTime := startTimestamp, 124 | _.endTime := endTimestamp, 125 | _.durationNs := 1 126 | ) 127 | } 128 | -------------------------------------------------------------------------------- /core/src/main/scala/sangria/federation/tracing/ApolloFederationTracing.scala: -------------------------------------------------------------------------------- 1 | package sangria.federation.tracing 2 | 3 | import com.google.protobuf.timestamp.Timestamp 4 | import reports.Trace 5 | import reports.Trace.{Location, Node} 6 | import sangria.ast._ 7 | import sangria.execution._ 8 | import sangria.marshalling.queryAst._ 9 | import sangria.renderer.SchemaRenderer 10 | import sangria.schema.Context 11 | import sangria.validation.AstNodeLocation 12 | 13 | import java.time.Instant 14 | import java.util.Base64 15 | import java.util.concurrent.ConcurrentLinkedQueue 16 | 17 | /** Implements https://www.apollographql.com/docs/federation/metrics/ 18 | */ 19 | object ApolloFederationTracing 20 | extends Middleware[Any] 21 | with MiddlewareAfterField[Any] 22 | with MiddlewareErrorField[Any] 23 | with MiddlewareExtension[Any] { 24 | type QueryVal = QueryTrace 25 | type FieldVal = Long 26 | 27 | def beforeQuery(context: MiddlewareQueryContext[Any, _, _]): QueryTrace = 28 | QueryTrace(Instant.now(), System.nanoTime(), new ConcurrentLinkedQueue) 29 | 30 | def afterQuery(queryVal: QueryVal, context: MiddlewareQueryContext[Any, _, _]): Unit = () 31 | 32 | def beforeField( 33 | queryVal: QueryVal, 34 | mctx: MiddlewareQueryContext[Any, _, _], 35 | ctx: Context[Any, _]): BeforeFieldResult[Any, FieldVal] = 36 | continue(System.nanoTime()) 37 | 38 | def afterField( 39 | queryVal: QueryVal, 40 | fieldVal: FieldVal, 41 | value: Any, 42 | mctx: MiddlewareQueryContext[Any, _, _], 43 | ctx: Context[Any, _]): None.type = { 44 | queryVal.fieldData.add(metricNode(queryVal, fieldVal, ctx)) 45 | None 46 | } 47 | 48 | def fieldError( 49 | queryVal: QueryVal, 50 | fieldVal: FieldVal, 51 | error: Throwable, 52 | mctx: MiddlewareQueryContext[Any, _, _], 53 | ctx: Context[Any, _]): Unit = { 54 | var node = metricNode(queryVal, fieldVal, ctx) 55 | error match { 56 | case e: AstNodeLocation => 57 | node = node.copy(error = Trace.Error( 58 | message = e.simpleErrorMessage, 59 | location = e.locations.map(l => Location(l.line, l.column))) :: Nil) 60 | case e: UserFacingError => 61 | node = node.copy(error = Trace.Error(message = e.getMessage) :: Nil) 62 | case _ => () 63 | } 64 | queryVal.fieldData.add(node) 65 | } 66 | 67 | private def metricNode(queryVal: QueryVal, fieldVal: FieldVal, ctx: Context[Any, _]): Node = 68 | Node( 69 | id = Node.Id.ResponseName(ctx.field.name), 70 | startTime = fieldVal - queryVal.startNanos, 71 | endTime = System.nanoTime() - queryVal.startNanos, 72 | `type` = SchemaRenderer.renderTypeName(ctx.field.fieldType), 73 | parentType = ctx.parentType.name, 74 | originalFieldName = ctx.field.name 75 | ) 76 | 77 | def afterQueryExtensions( 78 | queryVal: QueryVal, 79 | context: MiddlewareQueryContext[Any, _, _]): Vector[Extension[_]] = { 80 | val startNanos = queryVal.startNanos 81 | val endNanos = System.nanoTime() 82 | val root = Trace( 83 | startTime = Some(toTimestamp(startNanos)), 84 | endTime = Some(toTimestamp(endNanos)), 85 | durationNs = endNanos - startNanos, 86 | root = None 87 | ) 88 | val rootSerialized = StringValue(new String(Base64.getEncoder.encode(root.toByteArray))) 89 | Vector(Extension(ObjectValue("ftv1" -> rootSerialized): Value)) 90 | } 91 | 92 | private def toTimestamp(epochMilli: Long): Timestamp = 93 | Timestamp.of( 94 | epochMilli / 1000, 95 | (epochMilli % 1000).toInt * 1000000 96 | ) 97 | 98 | case class QueryTrace( 99 | startTime: Instant, 100 | startNanos: Long, 101 | fieldData: ConcurrentLinkedQueue[Node]) 102 | } 103 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | # This file was automatically generated by sbt-github-actions using the 2 | # githubWorkflowGenerate task. You should add and commit this file to 3 | # your git repository. It goes without saying that you shouldn't edit 4 | # this file by hand! Instead, if you wish to make changes, you should 5 | # change your sbt build configuration to revise the workflow description 6 | # to meet your needs, then regenerate this file. 7 | 8 | name: Continuous Integration 9 | 10 | on: 11 | pull_request: 12 | branches: ['**'] 13 | push: 14 | branches: ['**'] 15 | tags: [v*] 16 | 17 | env: 18 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 19 | 20 | jobs: 21 | build: 22 | name: Build and Test 23 | strategy: 24 | matrix: 25 | os: [ubuntu-latest] 26 | scala: [2.12.20, 2.13.16, 3.3.4] 27 | java: [zulu@8] 28 | runs-on: ${{ matrix.os }} 29 | steps: 30 | - name: Checkout current branch (full) 31 | uses: actions/checkout@v5 32 | with: 33 | fetch-depth: 0 34 | 35 | - name: Setup Java (zulu@8) 36 | if: matrix.java == 'zulu@8' 37 | uses: actions/setup-java@v5 38 | with: 39 | distribution: zulu 40 | java-version: 8 41 | cache: sbt 42 | 43 | - name: Setup sbt 44 | uses: sbt/setup-sbt@v1 45 | 46 | - name: Check formatting 47 | run: sbt '++ ${{ matrix.scala }}' scalafmtCheckAll 48 | 49 | - name: Check that workflows are up to date 50 | run: sbt '++ ${{ matrix.scala }}' githubWorkflowCheck 51 | 52 | - name: Build project 53 | run: sbt '++ ${{ matrix.scala }}' test 54 | 55 | - name: Compress target directories 56 | 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 57 | 58 | - name: Upload target directories 59 | uses: actions/upload-artifact@v4 60 | with: 61 | name: target-${{ matrix.os }}-${{ matrix.scala }}-${{ matrix.java }} 62 | path: targets.tar 63 | 64 | publish: 65 | name: Publish Artifacts 66 | needs: [build] 67 | if: github.event_name != 'pull_request' && (startsWith(github.ref, 'refs/tags/v')) 68 | strategy: 69 | matrix: 70 | os: [ubuntu-latest] 71 | scala: [3.3.4] 72 | java: [zulu@8] 73 | runs-on: ${{ matrix.os }} 74 | steps: 75 | - name: Checkout current branch (full) 76 | uses: actions/checkout@v5 77 | with: 78 | fetch-depth: 0 79 | 80 | - name: Setup Java (zulu@8) 81 | if: matrix.java == 'zulu@8' 82 | uses: actions/setup-java@v5 83 | with: 84 | distribution: zulu 85 | java-version: 8 86 | cache: sbt 87 | 88 | - name: Setup sbt 89 | uses: sbt/setup-sbt@v1 90 | 91 | - name: Download target directories (2.12.20) 92 | uses: actions/download-artifact@v5 93 | with: 94 | name: target-${{ matrix.os }}-2.12.20-${{ matrix.java }} 95 | 96 | - name: Inflate target directories (2.12.20) 97 | run: | 98 | tar xf targets.tar 99 | rm targets.tar 100 | 101 | - name: Download target directories (2.13.16) 102 | uses: actions/download-artifact@v5 103 | with: 104 | name: target-${{ matrix.os }}-2.13.16-${{ matrix.java }} 105 | 106 | - name: Inflate target directories (2.13.16) 107 | run: | 108 | tar xf targets.tar 109 | rm targets.tar 110 | 111 | - name: Download target directories (3.3.4) 112 | uses: actions/download-artifact@v5 113 | with: 114 | name: target-${{ matrix.os }}-3.3.4-${{ matrix.java }} 115 | 116 | - name: Inflate target directories (3.3.4) 117 | run: | 118 | tar xf targets.tar 119 | rm targets.tar 120 | 121 | - env: 122 | PGP_PASSPHRASE: ${{ secrets.PGP_PASSPHRASE }} 123 | PGP_SECRET: ${{ secrets.PGP_SECRET }} 124 | SONATYPE_PASSWORD: ${{ secrets.SONATYPE_PASSWORD }} 125 | SONATYPE_USERNAME: ${{ secrets.SONATYPE_USERNAME }} 126 | run: sbt ci-release 127 | -------------------------------------------------------------------------------- /core/src/main/scala/sangria/federation/v1/Federation.scala: -------------------------------------------------------------------------------- 1 | package sangria.federation.v1 2 | 3 | import sangria.ast 4 | import sangria.marshalling.{FromInput, InputUnmarshaller} 5 | import sangria.renderer.SchemaFilter 6 | import sangria.schema._ 7 | import sangria.util.tag.@@ 8 | 9 | object Federation { 10 | import Query._ 11 | 12 | def federate[Ctx, Val, Node]( 13 | schema: Schema[Ctx, Val], 14 | um: InputUnmarshaller[Node], 15 | resolvers: EntityResolver[Ctx, Node]* 16 | ): (Schema[Ctx, Val], InputUnmarshaller[Node]) = (extend(schema, resolvers), upgrade(um)) 17 | 18 | def extend[Ctx, Val, Node]( 19 | schema: Schema[Ctx, Val], 20 | resolvers: Seq[EntityResolver[Ctx, Node]]): Schema[Ctx, Val] = { 21 | val resolversMap = resolvers.map(r => r.typename -> r).toMap 22 | val representationsArg = Argument("representations", ListInputType(_Any.__type[Node])) 23 | 24 | val entities = schema.allTypes.values.collect { 25 | case obj: ObjectType[Ctx, _] @unchecked if obj.astDirectives.exists(_.name == "key") => obj 26 | }.toList 27 | 28 | val sdl = Some(schema.renderPretty(SchemaFilter.withoutGraphQLBuiltIn)) 29 | 30 | (entities match { 31 | case Nil => 32 | schema.extend( 33 | ast.Document(definitions = Vector(queryType(_service))), 34 | AstSchemaBuilder.resolverBased[Ctx]( 35 | FieldResolver.map("Query" -> Map("_service" -> (_ => _Service(sdl)))), 36 | AdditionalTypes(_Any.__type[Node], _Service.Type, _FieldSet.Type) 37 | ) 38 | ) 39 | case entities => 40 | schema.extend( 41 | ast.Document(definitions = Vector(queryType(_service, _entities))), 42 | AstSchemaBuilder.resolverBased[Ctx]( 43 | FieldResolver.map( 44 | "Query" -> Map( 45 | "_service" -> (_ => _Service(sdl)), 46 | "_entities" -> (ctx => 47 | ctx.withArgs(representationsArg) { (anys: Seq[_Any[Node]]) => 48 | Action.sequence(anys.map { (any: _Any[Node]) => 49 | val typeName = any.__typename 50 | val resolver = resolversMap.getOrElse( 51 | typeName, 52 | throw new Exception(s"no resolver found for type '$typeName'")) 53 | 54 | any.fields.decode[resolver.Arg](resolver.decoder) match { 55 | case Right(value) => resolver.resolve(value, ctx) 56 | case Left(e) => throw e 57 | } 58 | }) 59 | }) 60 | ) 61 | ), 62 | AdditionalTypes(_Any.__type[Node], _Service.Type, _Entity(entities), _FieldSet.Type) 63 | ) 64 | ) 65 | }).copy(directives = Directives.definitions ::: schema.directives) 66 | } 67 | 68 | def upgrade[Node](default: InputUnmarshaller[Node]): InputUnmarshaller[Node] = 69 | new InputUnmarshaller[Node] { 70 | 71 | override def getRootMapValue(node: Node, key: String): Option[Node] = 72 | default.getRootMapValue(node, key) 73 | override def isMapNode(node: Node): Boolean = default.isMapNode(node) 74 | override def getMapValue(node: Node, key: String): Option[Node] = 75 | default.getMapValue(node, key) 76 | override def getMapKeys(node: Node): Traversable[String] = default.getMapKeys(node) 77 | 78 | override def isListNode(node: Node): Boolean = default.isListNode(node) 79 | override def getListValue(node: Node): Seq[Node] = default.getListValue(node) 80 | 81 | override def isDefined(node: Node): Boolean = default.isDefined(node) 82 | override def isEnumNode(node: Node): Boolean = default.isEnumNode(node) 83 | override def isVariableNode(node: Node): Boolean = default.isVariableNode(node) 84 | 85 | override def getScalaScalarValue(node: Node): Any = 86 | default.getScalaScalarValue(node) 87 | 88 | override def getVariableName(node: Node): String = default.getVariableName(node) 89 | 90 | override def render(node: Node): String = default.render(node) 91 | 92 | override def isScalarNode(node: Node): Boolean = 93 | default.isMapNode(node) || default.isScalarNode(node) 94 | override def getScalarValue(node: Node): Any = 95 | if (default.isMapNode(node)) new NodeObject[Node] { 96 | 97 | override def __typename: Option[String] = 98 | getMapValue(node, "__typename").map(node => getScalarValue(node).asInstanceOf[String]) 99 | 100 | override def decode[T](implicit ev: Decoder[Node, T]): Either[Exception, T] = 101 | ev.decode(node) 102 | } 103 | else default.getScalarValue(node) 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /core/src/test/scala/sangria/federation/v2/ComposeDirectiveSpec.scala: -------------------------------------------------------------------------------- 1 | package sangria.federation.v2 2 | 3 | import org.scalatest.matchers.should.Matchers._ 4 | import org.scalatest.wordspec.AnyWordSpec 5 | import sangria.ast 6 | import sangria.federation.importFederationDirective 7 | import sangria.federation.v2.ComposeDirectiveSpec.{helloDirective, initialSchema, myDirective} 8 | import sangria.federation.v2.Directives.{ComposeDirective, Link} 9 | import sangria.macros._ 10 | import sangria.renderer.SchemaRenderer 11 | import sangria.schema._ 12 | import sangria.util.tag.@@ 13 | 14 | object ComposeDirectiveSpec { 15 | val initialSchema = Schema.buildFromAst(graphql""" 16 | schema { query: Query } 17 | type Query { field: Int } 18 | """) 19 | 20 | val myDirective: Directive = 21 | Directive("myDirective", locations = Set(DirectiveLocation.FieldDefinition)) 22 | 23 | val helloDirective: Directive = 24 | Directive("hello", locations = Set(DirectiveLocation.FieldDefinition)) 25 | } 26 | 27 | class ComposeDirectiveSpec extends AnyWordSpec { 28 | "@composeDirective" should { 29 | "import directives, using the high level API" in { 30 | val schema = Federation.extend( 31 | schema = initialSchema, 32 | customDirectives = CustomDirectivesDefinition( 33 | Spec("https://myspecs.dev/myDirective/v1.0") -> List(myDirective)), 34 | resolvers = Nil 35 | ) 36 | 37 | schema should importFederationDirective("@composeDirective") 38 | 39 | val renderedSchema = SchemaRenderer.renderSchema(schema) 40 | renderedSchema should include( 41 | """@link(url: "https://myspecs.dev/myDirective/v1.0", import: ["@myDirective"])""") 42 | renderedSchema should include("""@composeDirective(name: "@myDirective")""") 43 | renderedSchema should include("""directive @myDirective on FIELD_DEFINITION""") 44 | } 45 | 46 | "import directives in multiple specs, using the high level API" in { 47 | val schema = Federation.extend( 48 | schema = initialSchema, 49 | customDirectives = CustomDirectivesDefinition( 50 | Spec("https://myspecs.dev/myDirective/v1.0") -> List(myDirective), 51 | Spec("https://myspecs.dev/helloDirective/v2.0") -> List(helloDirective) 52 | ), 53 | resolvers = Nil 54 | ) 55 | 56 | schema should importFederationDirective("@composeDirective") 57 | 58 | val renderedSchema = SchemaRenderer.renderSchema(schema) 59 | renderedSchema should include( 60 | """@link(url: "https://myspecs.dev/myDirective/v1.0", import: ["@myDirective"])""") 61 | renderedSchema should include( 62 | """@link(url: "https://myspecs.dev/helloDirective/v2.0", import: ["@hello"])""") 63 | renderedSchema should include("""@composeDirective(name: "@myDirective")""") 64 | renderedSchema should include("""@composeDirective(name: "@hello")""") 65 | renderedSchema should include("""directive @myDirective on FIELD_DEFINITION""") 66 | renderedSchema should include("""directive @hello on FIELD_DEFINITION""") 67 | } 68 | 69 | "import directives with alias, using the low level API" in { 70 | val additionalLinkImports: List[ast.Directive @@ Link] = List( 71 | Link( 72 | url = "https://myspecs.dev/myDirective/v1.0", 73 | `import` = Some( 74 | Vector( 75 | Link__Import("@" + myDirective.name), 76 | Link__Import("@anotherDirective").as("@hello") 77 | )))) 78 | 79 | val composeDirectives: List[ast.Directive @@ ComposeDirective] = List( 80 | Directives.ComposeDirective(myDirective), 81 | Directives.ComposeDirective(helloDirective) 82 | ) 83 | 84 | val schemaWithDirectives = initialSchema.copy( 85 | directives = myDirective :: helloDirective :: initialSchema.directives 86 | ) 87 | 88 | val schema = Federation.extend( 89 | schema = schemaWithDirectives, 90 | additionalLinkImports = additionalLinkImports, 91 | composeDirectives = composeDirectives, 92 | resolvers = Nil 93 | ) 94 | 95 | schema should importFederationDirective("@composeDirective") 96 | 97 | val renderedSchema = SchemaRenderer.renderSchema(schema) 98 | renderedSchema should include( 99 | """@link(url: "https://myspecs.dev/myDirective/v1.0", import: ["@myDirective", {name: "@anotherDirective", as: "@hello"}])""") 100 | renderedSchema should include("""@composeDirective(name: "@myDirective")""") 101 | renderedSchema should include("""@composeDirective(name: "@hello")""") 102 | renderedSchema should include("""directive @myDirective on FIELD_DEFINITION""") 103 | renderedSchema should include("""directive @hello on FIELD_DEFINITION""") 104 | } 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /core/src/test/scala/sangria/federation/v2/DirectivesSpec.scala: -------------------------------------------------------------------------------- 1 | package sangria.federation.v2 2 | 3 | import org.scalatest.matchers.must.Matchers._ 4 | import org.scalatest.wordspec.AnyWordSpec 5 | import sangria.federation.renderLike 6 | 7 | class DirectivesSpec extends AnyWordSpec { 8 | "directives for federation v2" should { 9 | "support @link directive" in { 10 | // https://www.apollographql.com/docs/federation/federated-types/federated-directives#the-link-directive 11 | // https://specs.apollo.dev/link/v1.0/#@link 12 | Directives.Link("https://example.com/otherSchema") must renderLike( 13 | """@link(url: "https://example.com/otherSchema")""") 14 | Directives.Link("https://example.com/otherSchema", as = "eg") must renderLike( 15 | """@link(url: "https://example.com/otherSchema", as: "eg")""") 16 | Directives.Link( 17 | "https://example.com/otherSchema", 18 | `import` = Some(Vector(Link__Import("@link"), Link__Import("Purpose")))) must renderLike( 19 | """@link(url: "https://example.com/otherSchema", import: ["@link", "Purpose"])""") 20 | Directives.Link( 21 | "https://example.com/otherSchema", 22 | `import` = Some(Vector(Link__Import_Object("@example"), Link__Import_Object("Purpose"))) 23 | ) must renderLike( 24 | """@link(url: "https://example.com/otherSchema", import: [{name: "@example"}, {name: "Purpose"}])""") 25 | Directives.Link( 26 | "https://example.com/otherSchema", 27 | `import` = Some( 28 | Vector( 29 | Link__Import_Object("@example", Some("@eg")), 30 | Link__Import_Object("Purpose", Some("LinkPurpose")))) 31 | ) must renderLike( 32 | """@link(url: "https://example.com/otherSchema", import: [{name: "@example", as: "@eg"}, {name: "Purpose", as: "LinkPurpose"}])""") 33 | Directives.Link("https://example.com/otherSchema", `for` = Link__Purpose.EXECUTION) must 34 | renderLike("""@link(url: "https://example.com/otherSchema", for: EXECUTION)""") 35 | Directives.Link("https://example.com/otherSchema", `for` = Link__Purpose.SECURITY) must 36 | renderLike("""@link(url: "https://example.com/otherSchema", for: SECURITY)""") 37 | } 38 | 39 | "support @key directive" in { 40 | // https://www.apollographql.com/docs/federation/federated-types/federated-directives#key 41 | Directives.Key("id") must renderLike("""@key(fields: "id")""") 42 | Directives.Key("id", resolvable = true) must 43 | renderLike("""@key(fields: "id", resolvable: true)""") 44 | Directives.Key("id", resolvable = false) must 45 | renderLike("""@key(fields: "id", resolvable: false)""") 46 | } 47 | 48 | "support @interfaceObject directive" in { 49 | // https://www.apollographql.com/docs/federation/federated-types/federated-directives#interfaceobject 50 | Directives.InterfaceObject must renderLike("@interfaceObject") 51 | } 52 | 53 | "support @extends directive" in { 54 | // https://www.apollographql.com/docs/federation/federated-types/federated-directives#extends 55 | Directives.Extends must renderLike("@extends") 56 | } 57 | 58 | "support @shareable directive" in { 59 | // https://www.apollographql.com/docs/federation/federated-types/federated-directives#shareable 60 | Directives.Shareable must renderLike("@shareable") 61 | } 62 | 63 | "support @inaccessible directive" in { 64 | // https://www.apollographql.com/docs/federation/federated-types/federated-directives#inaccessible 65 | Directives.Inaccessible must renderLike("@inaccessible") 66 | } 67 | 68 | "support @override directive" in { 69 | // https://www.apollographql.com/docs/federation/federated-types/federated-directives#override 70 | Directives.Override(from = "Products") must renderLike("""@override(from: "Products")""") 71 | } 72 | 73 | "support @external directive" in { 74 | // https://www.apollographql.com/docs/federation/federated-types/federated-directives#external 75 | Directives.External must renderLike("@external") 76 | } 77 | 78 | "support @provides directive" in { 79 | // https://www.apollographql.com/docs/federation/federated-types/federated-directives#provides 80 | Directives.Provides(fields = "name") must renderLike("""@provides(fields: "name")""") 81 | } 82 | 83 | "support @requires directive" in { 84 | // https://www.apollographql.com/docs/federation/federated-types/federated-directives#requires 85 | Directives.Requires(fields = "size weight") must 86 | renderLike("""@requires(fields: "size weight")""") 87 | } 88 | 89 | "support @tag directive" in { 90 | // https://www.apollographql.com/docs/federation/federated-types/federated-directives#tag 91 | Directives.Tag(name = "team-admin") must renderLike("""@tag(name: "team-admin")""") 92 | } 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Sangria Logo](https://sangria-graphql.github.io/assets/img/sangria-logo.svg) 2 | 3 | # sangria-federated 4 | 5 | ![Continuous Integration](https://github.com/sangria-graphql/sangria-federated/workflows/Continuous%20Integration/badge.svg) 6 | [![Maven Central](https://maven-badges.herokuapp.com/maven-central/org.sangria-graphql/sangria-federated_2.13/badge.svg)](https://maven-badges.herokuapp.com/maven-central/org.sangria-graphql/sangria-federated_2.13) 7 | [![License](http://img.shields.io/:license-Apache%202-brightgreen.svg)](http://www.apache.org/licenses/LICENSE-2.0.txt) 8 | [![Scaladocs](https://www.javadoc.io/badge/org.sangria-graphql/sangria-federated_2.13.svg?label=docs)](https://www.javadoc.io/doc/org.sangria-graphql/sangria-federated_2.13) 9 | [![Join the chat at https://gitter.im/sangria-graphql/sangria](https://badges.gitter.im/Join%20Chat.svg)](https://gitter.im/sangria-graphql/sangria?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) 10 | 11 | **sangria-federated** is a library that allows sangria users to implement services that adhere to [Apollo's Federation Specification](https://www.apollographql.com/docs/federation/federation-spec/), and can be used as part of a federated data graph. 12 | 13 | SBT Configuration: 14 | 15 | ```scala 16 | libraryDependencies += "org.sangria-graphql" %% "sangria-federated" % "" 17 | ``` 18 | 19 | ## How does it work? 20 | 21 | The library adds [Apollo's Federation Specification](https://www.apollographql.com/docs/federation/federation-spec/) on top of the provided sangria graphql schema. 22 | 23 | To make it possible to use `_Any` as a scalar, the library upgrades the used marshaller. 24 | 25 | ## Implementation of the Apollo Federation Subgraph Compatibility 26 | 27 | A good example showing all different features is the [sangria implementation of the Apollo Federation Subgraph Compatibility](https://github.com/apollographql/apollo-federation-subgraph-compatibility/tree/main/implementations/sangria). 28 | 29 | ## Example how to use it 30 | 31 | All the code of this example is available [here](./example). 32 | 33 | To be able to communicate with [Apollo's federation gateway](https://www.apollographql.com/docs/federation/gateway/), the graphql sangria service should be using both the federated schema and unmarshaller. 34 | 35 | As an example, let's consider the following services: 36 | - a **review service** provides a subgraph for review 37 | - a **state service** provides a subgraph for states. This state is used by reviews. 38 | - both subgraphs are composed into **one supergraph** that is the only graph exposed to users. With that, users can interact with reviews and states as if they were implemented in one service. 39 | 40 | ### The state service 41 | 42 | The state service defines the state entity annotated with `@key("id")`. 43 | 44 | For each entity, we need to define an [entity resolver](https://www.apollographql.com/docs/federation/entities/#resolving). 45 | 46 | It's highly recommended to use [Deferred Value Resolution](https://sangria-graphql.github.io/learn/#deferred-value-resolution) in those resolvers to batch the fetching of the entities. 47 | 48 | [Implementation of the State GraphQL API](./example/state/src/main/scala/state/StateGraphQLSchema.scala). 49 | 50 | The entity resolver implements: 51 | - the deserialization of the fields in `_Any` object to the EntityArg. 52 | - how to fetch the proper Entity (in our case `State`) based on the EntityArg. 53 | 54 | In the definition of the GraphQL server, we federate the Query type and the unmarshaller while supplying the entity resolvers. 55 | Then, we use both the federated schema and unmarshaller as arguments for the server: 56 | 57 | ```scala 58 | def graphQL[F[_]: Async]: GraphQL[F, StateService] = { 59 | val (schema, um) = Federation.federate[StateService, Any, Json]( 60 | Schema(StateAPI.Query), 61 | sangria.marshalling.circe.CirceInputUnmarshaller, 62 | stateResolver) 63 | 64 | GraphQL(schema, DeferredResolver.fetchers(StateGraphQLSchema.states), ctx.pure[F])(Async[F], um) 65 | } 66 | ``` 67 | 68 | The `stateResolver` delegates the resolution of the state entities to a [`Fetcher`](https://sangria-graphql.github.io/learn/#high-level-fetch-api), 69 | allowing the state service to resolve the state entities based on the provided ids in one batch. 70 | 71 | The GraphQL server uses the provided schema and unmarshaller as arguments for the sangria executor: 72 | [implementation](./example/common/src/main/scala/common/GraphQL.scala) 73 | 74 | ### The review service 75 | 76 | - The review service defines the `Review` type, which has a reference to the `State` type. 77 | 78 | [implementation of Review GraphQL API](./example/review/src/main/scala/review/ReviewGraphQLSchema.scala) 79 | 80 | - As `State` is implemented by the state service, we don't need to implement the whole state in the review service. 81 | Instead, for each entity implemented by another service, a [stub type](https://www.apollographql.com/docs/federation/entities/#referencing) should be created (containing just the minimal information that will allow to reference the entity). 82 | 83 | [implementation of the stub type State](./example/review/src/main/scala/review/State.scala) 84 | (notice the usage of the @external directive). 85 | 86 | - In the end, the same code used to federate the state service is used to federate the review service. 87 | 88 | ### Federation router 89 | 90 | The [federation router](https://www.apollographql.com/docs/router/) can expose the GraphQL endpoint, and resolve any GraphQL query using our sangria GraphQL services. 91 | 92 | The sangria GraphQL services endpoints are configured in the [supergraph configuration](./example/router/supergraph-local.yaml), used by rover to compose the supergraph: 93 | ``` 94 | federation_version: 2 95 | subgraphs: 96 | state: 97 | schema: 98 | subgraph_url: http://localhost:9081/api/graphql 99 | review: 100 | schema: 101 | subgraph_url: http://localhost:9082/api/graphql 102 | ``` 103 | 104 | ## Caution 🚨🚨 105 | 106 | - **This is a technology preview. We are actively working on it and cannot promise a stable API yet**. 107 | - It's highly recommended to use [Deferred Value Resolution](https://sangria-graphql.github.io/learn/#deferred-value-resolution) in the `EntityResolver` to batch the fetching of the entities. 108 | - The library upgrades the marshaller to map values scalars (e.g. json objects as scalars). This can lead to security issues as discussed [here](http://www.petecorey.com/blog/2017/06/12/graphql-nosql-injection-through-json-types/). 109 | 110 | ## Contribute 111 | 112 | Contributions are warmly desired 🤗. Please follow the standard process of forking the repo and making PRs 🤓 113 | 114 | ## License 115 | 116 | **sangria-federated** is licensed under [Apache License, Version 2.0](http://www.apache.org/licenses/LICENSE-2.0). 117 | -------------------------------------------------------------------------------- /example/product/src/main/scala/graphql/ProductGraphQLSchema.scala: -------------------------------------------------------------------------------- 1 | package graphql 2 | 3 | import graphql.CustomDirectiveSpec.CustomDirective 4 | import graphql.UserGraphQLSchema.UserType 5 | import io.circe.Json 6 | import io.circe.generic.semiauto._ 7 | import model._ 8 | import sangria.federation.v2.Directives._ 9 | import sangria.federation.v2.{Decoder, EntityResolver} 10 | import sangria.schema._ 11 | 12 | object ProductGraphQLSchema { 13 | private val ProductVariationType: ObjectType[Unit, ProductVariation] = ObjectType( 14 | "ProductVariation", 15 | fields = fields[Unit, ProductVariation]( 16 | Field("id", IDType, resolve = _.value.id.value) 17 | ) 18 | ) 19 | 20 | private val ProductDimensionType: ObjectType[Unit, ProductDimension] = ObjectType( 21 | "ProductDimension", 22 | fields = fields[Unit, ProductDimension]( 23 | Field("size", OptionType(StringType), resolve = _.value.size), 24 | Field("weight", OptionType(FloatType), resolve = _.value.weight), 25 | Field( 26 | "unit", 27 | OptionType(StringType), 28 | resolve = _.value.unit, 29 | astDirectives = Vector(Inaccessible)) 30 | ) 31 | ).withDirective(Shareable) 32 | 33 | private val CaseStudyType: ObjectType[Unit, CaseStudy] = ObjectType( 34 | "CaseStudy", 35 | fields = fields[Unit, CaseStudy]( 36 | Field("caseNumber", IDType, resolve = _.value.caseNumber.value), 37 | Field("description", OptionType(StringType), resolve = _.value.description) 38 | ) 39 | ) 40 | 41 | private val ProductResearchType: ObjectType[Unit, ProductResearch] = ObjectType( 42 | "ProductResearch", 43 | fields = fields[Unit, ProductResearch]( 44 | Field("study", CaseStudyType, resolve = _.value.study), 45 | Field("outcome", OptionType(StringType), resolve = _.value.outcome) 46 | ) 47 | ).withDirective(Key("study { caseNumber }")) 48 | 49 | private val ProductType: ObjectType[Unit, Product] = ObjectType( 50 | "Product", 51 | fields = fields[Unit, Product]( 52 | Field("id", IDType, resolve = _.value.id.value), 53 | Field("sku", OptionType(StringType), resolve = _.value.sku), 54 | Field("package", OptionType(StringType), resolve = _.value.`package`), 55 | Field("variation", OptionType(ProductVariationType), resolve = _.value.variation), 56 | Field("dimensions", OptionType(ProductDimensionType), resolve = _.value.dimensions), 57 | Field( 58 | "createdBy", 59 | OptionType(UserType), 60 | resolve = _.value.createdBy, 61 | astDirectives = Vector(Provides("totalProductsCreated")) 62 | ), 63 | Field( 64 | "notes", 65 | OptionType(StringType), 66 | resolve = _.value.notes, 67 | astDirectives = Vector(Tag("internal"))), 68 | Field("research", ListType(ProductResearchType), resolve = _.value.research) 69 | ) 70 | ).withDirectives( 71 | CustomDirective, 72 | Key("id"), 73 | Key("sku package"), 74 | Key("sku variation { id }") 75 | ) 76 | 77 | val DeprecatedProductType: ObjectType[Unit, DeprecatedProduct] = ObjectType( 78 | "DeprecatedProduct", 79 | fields = fields[Unit, DeprecatedProduct]( 80 | Field("sku", StringType, resolve = _.value.sku), 81 | Field("package", StringType, resolve = _.value.`package`), 82 | Field("reason", OptionType(StringType), resolve = _.value.reason), 83 | Field("createdBy", OptionType(UserType), resolve = _.value.createdBy) 84 | ) 85 | ).withDirective(Key("sku package")) 86 | 87 | val IdArg: Argument[String] = Argument("id", IDType) 88 | val SkuArg: Argument[String] = Argument("sku", StringType) 89 | val PackageArg: Argument[String] = Argument("package", StringType) 90 | 91 | val productQueryField: Field[AppContext, Unit] = Field( 92 | "product", 93 | OptionType(ProductType), 94 | arguments = List(IdArg), 95 | resolve = ctx => ctx.ctx.productService.product(ID(ctx.arg(IdArg))) 96 | ) 97 | 98 | val deprecatedProductQueryField: Field[AppContext, Unit] = Field( 99 | name = "deprecatedProduct", 100 | fieldType = OptionType(DeprecatedProductType), 101 | deprecationReason = Some("Use product query instead"), 102 | arguments = List(SkuArg, PackageArg), 103 | resolve = ctx => ctx.ctx.productService.deprecatedProduct(ctx.arg(SkuArg), ctx.arg(PackageArg)) 104 | ) 105 | 106 | case class DeprecatedProductArgs(sku: String, `package`: String) 107 | object DeprecatedProductArgs { 108 | val jsonDecoder: io.circe.Decoder[DeprecatedProductArgs] = deriveDecoder[DeprecatedProductArgs] 109 | implicit val decoder: Decoder[Json, DeprecatedProductArgs] = jsonDecoder.decodeJson 110 | } 111 | 112 | case class CaseStudyArgs(caseNumber: ID) 113 | object CaseStudyArgs { 114 | implicit val jsonDecoder: io.circe.Decoder[CaseStudyArgs] = deriveDecoder[CaseStudyArgs] 115 | } 116 | 117 | case class ProductResearchArgs(study: CaseStudyArgs) 118 | object ProductResearchArgs { 119 | val jsonDecoder: io.circe.Decoder[ProductResearchArgs] = deriveDecoder[ProductResearchArgs] 120 | implicit val decoder: Decoder[Json, ProductResearchArgs] = jsonDecoder.decodeJson 121 | } 122 | 123 | sealed trait ProductArgs 124 | 125 | object ProductArgs { 126 | import cats.syntax.functor._ 127 | 128 | case class IdOnly(id: ID) extends ProductArgs 129 | object IdOnly { 130 | implicit val jsonDecoder: io.circe.Decoder[IdOnly] = deriveDecoder[IdOnly] 131 | } 132 | 133 | case class SkuAndPackage(sku: String, `package`: String) extends ProductArgs 134 | object SkuAndPackage { 135 | implicit val jsonDecoder: io.circe.Decoder[SkuAndPackage] = deriveDecoder[SkuAndPackage] 136 | } 137 | 138 | case class SkuAndVariationId(sku: String, variation: ProductVariation) extends ProductArgs 139 | object SkuAndVariationId { 140 | implicit val jsonDecoder: io.circe.Decoder[SkuAndVariationId] = 141 | deriveDecoder[SkuAndVariationId] 142 | } 143 | 144 | val jsonDecoder: io.circe.Decoder[ProductArgs] = List[io.circe.Decoder[ProductArgs]]( 145 | io.circe.Decoder[IdOnly].widen, 146 | io.circe.Decoder[SkuAndPackage].widen, 147 | io.circe.Decoder[SkuAndVariationId].widen 148 | ).reduceLeft(_ or _) 149 | implicit val decoder: Decoder[Json, ProductArgs] = jsonDecoder.decodeJson 150 | } 151 | 152 | implicit val decoder: Decoder[Json, ID] = ID.decoder.decodeJson 153 | 154 | def productResearchResolver: EntityResolver[AppContext, Json] { type Arg = ProductResearchArgs } = 155 | EntityResolver[AppContext, Json, ProductResearch, ProductResearchArgs]( 156 | ProductResearchType.name, 157 | (arg, ctx) => ctx.ctx.productResearchService.productResearch(arg.study.caseNumber) 158 | ) 159 | 160 | def productResolver: EntityResolver[AppContext, Json] { type Arg = ProductArgs } = 161 | EntityResolver[AppContext, Json, Product, ProductArgs]( 162 | ProductType.name, 163 | (arg, ctx) => 164 | arg match { 165 | case ProductArgs.IdOnly(id) => ctx.ctx.productService.product(id) 166 | case ProductArgs.SkuAndPackage(sku, pack) => 167 | ctx.ctx.productService.bySkuAndPackage(sku, pack) 168 | case ProductArgs.SkuAndVariationId(sku, variation) => 169 | ctx.ctx.productService.bySkuAndProductVariantionId(sku, variation) 170 | } 171 | ) 172 | 173 | def deprecatedProductResolver 174 | : EntityResolver[AppContext, Json] { type Arg = DeprecatedProductArgs } = 175 | EntityResolver[AppContext, Json, DeprecatedProduct, DeprecatedProductArgs]( 176 | DeprecatedProductType.name, 177 | (arg, ctx) => ctx.ctx.productService.deprecatedProduct(arg.sku, arg.`package`) 178 | ) 179 | } 180 | -------------------------------------------------------------------------------- /core/src/main/scala/sangria/federation/v2/Directives.scala: -------------------------------------------------------------------------------- 1 | package sangria.federation.v2 2 | 3 | import sangria.ast 4 | import sangria.schema._ 5 | import sangria.util.tag.@@ 6 | import sangria.util.tag 7 | 8 | object Directives { 9 | 10 | trait Link 11 | 12 | object Link { 13 | val definition: Directive @@ Link = tag[Link]( 14 | Directive( 15 | name = "link", 16 | arguments = List( 17 | Argument("url", StringType), 18 | Argument("as", OptionInputType(StringType)), 19 | Argument("for", OptionInputType(Link__Purpose.Type)), 20 | Argument("import", OptionInputType(ListInputType(OptionInputType(Link__Import.Type)))) 21 | ), 22 | locations = Set(DirectiveLocation.Schema), 23 | repeatable = true 24 | )) 25 | 26 | def apply(url: String, as: String): ast.Directive @@ Link = 27 | apply(url = url, as = Some(as)) 28 | 29 | def apply(url: String, `for`: Link__Purpose.Value): ast.Directive @@ Link = 30 | apply(url = url, `for` = Some(`for`)) 31 | 32 | def apply( 33 | url: String, 34 | as: Option[String] = None, 35 | `for`: Option[Link__Purpose.Value] = None, 36 | `import`: Option[Vector[Abstract_Link__Import]] = None): ast.Directive @@ Link = 37 | tag[Link]( 38 | ast.Directive( 39 | name = "link", 40 | arguments = Vector( 41 | Some(ast.Argument("url", ast.StringValue(url))), 42 | as.map(v => ast.Argument("as", ast.StringValue(v))), 43 | `for`.map(v => ast.Argument("for", ast.EnumValue(Link__Purpose.Type.coerceOutput(v)))), 44 | `import`.map(v => 45 | ast.Argument("import", ast.ListValue(v.map(v => Abstract_Link__Import.toAst(v))))) 46 | ).flatten 47 | )) 48 | } 49 | 50 | /** [@key](https://www.apollographql.com/docs/federation/federated-types/federated-directives#key) 51 | * directive 52 | */ 53 | object Key { 54 | val definition: Directive = Directive( 55 | name = "key", 56 | arguments = List( 57 | Argument("fields", _FieldSet.Type), 58 | Argument("resolvable", OptionInputType(BooleanType), defaultValue = true)), 59 | locations = Set(DirectiveLocation.Object, DirectiveLocation.Interface), 60 | repeatable = true 61 | ) 62 | 63 | def apply(fields: String, resolvable: Boolean): ast.Directive = 64 | apply(fields, resolvable = Some(resolvable)) 65 | 66 | def apply(fields: String, resolvable: Option[Boolean] = None): ast.Directive = 67 | ast.Directive( 68 | name = "key", 69 | arguments = Vector( 70 | Some(ast.Argument("fields", ast.StringValue(fields))), 71 | resolvable.map(r => ast.Argument("resolvable", ast.BooleanValue(r)))).flatten 72 | ) 73 | } 74 | 75 | /** [@interfaceObject](https://www.apollographql.com/docs/federation/federated-types/federated-directives/#interfaceobject) 76 | * directive definition 77 | */ 78 | val InterfaceObjectDefinition: Directive = 79 | Directive(name = "interfaceObject", locations = Set(DirectiveLocation.Object)) 80 | 81 | /** [@interfaceObject](https://www.apollographql.com/docs/federation/federated-types/federated-directives#interfaceobject) 82 | * directive 83 | */ 84 | val InterfaceObject: ast.Directive = ast.Directive(name = "interfaceObject") 85 | 86 | /** [@extends](https://www.apollographql.com/docs/federation/federated-types/federated-directives#extends) 87 | * directive definition 88 | */ 89 | val ExtendsDefinition: Directive = Directive( 90 | name = "extends", 91 | locations = Set(DirectiveLocation.Object, DirectiveLocation.Interface)) 92 | 93 | /** [@extends](https://www.apollographql.com/docs/federation/federated-types/federated-directives#extends) 94 | * directive 95 | */ 96 | val Extends: ast.Directive = ast.Directive(name = "extends") 97 | 98 | /** [@shareable](https://www.apollographql.com/docs/federation/federated-types/federated-directives#shareable) 99 | * directive definition 100 | */ 101 | val ShareableDefinition: Directive = Directive( 102 | name = "shareable", 103 | locations = Set(DirectiveLocation.Object, DirectiveLocation.FieldDefinition), 104 | repeatable = true 105 | ) 106 | 107 | /** [@shareable](https://www.apollographql.com/docs/federation/federated-types/federated-directives#shareable) 108 | * directive 109 | */ 110 | val Shareable: ast.Directive = ast.Directive(name = "shareable") 111 | 112 | /** [@inaccessible](https://www.apollographql.com/docs/federation/federated-types/federated-directives#inaccessible) 113 | * directive definition 114 | */ 115 | val InaccessibleDefinition: Directive = Directive( 116 | name = "inaccessible", 117 | locations = Set( 118 | DirectiveLocation.FieldDefinition, 119 | DirectiveLocation.Object, 120 | DirectiveLocation.Interface, 121 | DirectiveLocation.Union, 122 | DirectiveLocation.ArgumentDefinition, 123 | DirectiveLocation.Scalar, 124 | DirectiveLocation.Enum, 125 | DirectiveLocation.EnumValue, 126 | DirectiveLocation.InputObject, 127 | DirectiveLocation.InputFieldDefinition 128 | ) 129 | ) 130 | 131 | /** [@inaccessible](https://www.apollographql.com/docs/federation/federated-types/federated-directives#inaccessible) 132 | * directive 133 | */ 134 | val Inaccessible: ast.Directive = ast.Directive(name = "inaccessible") 135 | 136 | /** [@override](https://www.apollographql.com/docs/federation/federated-types/federated-directives#override) 137 | * directive 138 | */ 139 | object Override { 140 | val Definition: Directive = Directive( 141 | name = "override", 142 | arguments = List(Argument("from", StringType)), 143 | locations = Set(DirectiveLocation.FieldDefinition) 144 | ) 145 | 146 | def apply(from: String): ast.Directive = 147 | ast.Directive( 148 | name = "override", 149 | arguments = Vector(ast.Argument("from", ast.StringValue(from)))) 150 | } 151 | 152 | /** [@external](https://www.apollographql.com/docs/federation/federated-types/federated-directives#external) 153 | * directive definition 154 | */ 155 | val ExternalDefinition: Directive = 156 | Directive(name = "external", locations = Set(DirectiveLocation.FieldDefinition)) 157 | 158 | /** [@external](https://www.apollographql.com/docs/federation/federated-types/federated-directives#external) 159 | * directive 160 | */ 161 | val External: ast.Directive = ast.Directive(name = "external") 162 | 163 | /** [@provides](https://www.apollographql.com/docs/federation/federated-types/federated-directives#provides) 164 | * directive 165 | */ 166 | object Provides { 167 | val definition: Directive = Directive( 168 | name = "provides", 169 | arguments = Argument("fields", _FieldSet.Type) :: Nil, 170 | locations = Set(DirectiveLocation.FieldDefinition)) 171 | 172 | def apply(fields: String): ast.Directive = 173 | ast.Directive( 174 | name = "provides", 175 | arguments = Vector(ast.Argument("fields", ast.StringValue(fields)))) 176 | } 177 | 178 | /** [@requires](https://www.apollographql.com/docs/federation/federated-types/federated-directives#requires) 179 | * directive 180 | */ 181 | object Requires { 182 | val definition: Directive = Directive( 183 | name = "requires", 184 | arguments = Argument("fields", _FieldSet.Type) :: Nil, 185 | locations = Set(DirectiveLocation.FieldDefinition)) 186 | 187 | def apply(fields: String): ast.Directive = 188 | ast.Directive( 189 | name = "requires", 190 | arguments = Vector(ast.Argument("fields", ast.StringValue(fields)))) 191 | } 192 | 193 | /** [@tag](https://www.apollographql.com/docs/federation/federated-types/federated-directives#tag) 194 | * directive 195 | */ 196 | object Tag { 197 | val definition: Directive = Directive( 198 | name = "tag", 199 | arguments = List( 200 | Argument("name", StringType) 201 | ), 202 | locations = Set( 203 | DirectiveLocation.FieldDefinition, 204 | DirectiveLocation.Object, 205 | DirectiveLocation.Interface, 206 | DirectiveLocation.Union, 207 | DirectiveLocation.ArgumentDefinition, 208 | DirectiveLocation.Scalar, 209 | DirectiveLocation.Enum, 210 | DirectiveLocation.EnumValue, 211 | DirectiveLocation.InputObject, 212 | DirectiveLocation.InputFieldDefinition 213 | ), 214 | repeatable = true 215 | ) 216 | 217 | def apply(name: String): ast.Directive = 218 | ast.Directive(name = "tag", arguments = Vector(ast.Argument("name", ast.StringValue(name)))) 219 | } 220 | 221 | trait ComposeDirective 222 | 223 | /** [@composeDirective](https://www.apollographql.com/docs/federation/federated-types/federated-directives#composedirective) 224 | */ 225 | object ComposeDirective { 226 | val definition: Directive @@ ComposeDirective = tag[ComposeDirective]( 227 | Directive( 228 | name = "composeDirective", 229 | arguments = List(Argument("name", StringType)), 230 | locations = Set(DirectiveLocation.Schema), 231 | repeatable = true 232 | )) 233 | 234 | def apply(name: String): ast.Directive @@ ComposeDirective = 235 | tag[ComposeDirective]( 236 | ast.Directive( 237 | name = "composeDirective", 238 | arguments = Vector(ast.Argument("name", ast.StringValue(name))))) 239 | 240 | def apply(directive: Directive): ast.Directive @@ ComposeDirective = 241 | apply("@" + directive.name) 242 | } 243 | } 244 | -------------------------------------------------------------------------------- /core/src/main/scala/sangria/federation/v2/Federation.scala: -------------------------------------------------------------------------------- 1 | package sangria.federation.v2 2 | 3 | import sangria.ast 4 | import sangria.federation.v2._Any 5 | import sangria.federation.v2.Directives.{ComposeDirective, Link} 6 | import sangria.marshalling.InputUnmarshaller 7 | import sangria.renderer.SchemaFilter 8 | import sangria.schema._ 9 | import sangria.util.tag.@@ 10 | 11 | case class Spec(url: String) extends AnyVal 12 | 13 | /** Those custom directives will be exposed as 14 | * [[https://www.apollographql.com/docs/federation/federated-types/federated-directives#composedirective `@composeDirectives`]] 15 | */ 16 | case class CustomDirectivesDefinition(included: Map[Spec, List[Directive]]) 17 | object CustomDirectivesDefinition { 18 | def apply(item: (Spec, List[Directive])*): CustomDirectivesDefinition = 19 | new CustomDirectivesDefinition(item.toMap) 20 | } 21 | 22 | object Federation { 23 | import Query._ 24 | 25 | def federate[Ctx, Val, Node]( 26 | schema: Schema[Ctx, Val], 27 | um: InputUnmarshaller[Node], 28 | resolvers: EntityResolver[Ctx, Node]* 29 | ): (Schema[Ctx, Val], InputUnmarshaller[Node]) = 30 | (extend(schema, Nil, Nil, resolvers), upgrade(um)) 31 | 32 | def federate[Ctx, Val, Node]( 33 | schema: Schema[Ctx, Val], 34 | customDirectives: CustomDirectivesDefinition, 35 | um: InputUnmarshaller[Node], 36 | resolvers: EntityResolver[Ctx, Node]* 37 | ): (Schema[Ctx, Val], InputUnmarshaller[Node]) = 38 | (extend(schema, customDirectives, resolvers), upgrade(um)) 39 | 40 | def extend[Ctx, Val, Node]( 41 | schema: Schema[Ctx, Val], 42 | resolvers: Seq[EntityResolver[Ctx, Node]]): Schema[Ctx, Val] = 43 | extend(schema, Nil, Nil, resolvers) 44 | 45 | /** High level API allowing to expose custom directives 46 | */ 47 | def extend[Ctx, Val, Node]( 48 | schema: Schema[Ctx, Val], 49 | customDirectives: CustomDirectivesDefinition, 50 | resolvers: Seq[EntityResolver[Ctx, Node]]): Schema[Ctx, Val] = { 51 | 52 | val additionalLinkImports: List[ast.Directive @@ Link] = 53 | customDirectives.included.iterator.map { case (spec, directives) => 54 | Directives.Link( 55 | url = spec.url, 56 | `import` = Some(directives.iterator.map(d => Link__Import("@" + d.name)).toVector) 57 | ) 58 | }.toList 59 | val composeDirectives: List[ast.Directive @@ ComposeDirective] = 60 | customDirectives.included.values.flatMap { directives => 61 | directives.map(d => Directives.ComposeDirective(d)) 62 | }.toList 63 | val schemaWithCustomDirectives = 64 | schema.copy(directives = schema.directives ++ customDirectives.included.values.flatten) 65 | extend(schemaWithCustomDirectives, additionalLinkImports, composeDirectives, resolvers) 66 | } 67 | 68 | def extend[Ctx, Val, Node]( 69 | schema: Schema[Ctx, Val], 70 | additionalLinkImports: List[ast.Directive @@ Link], 71 | composeDirectives: List[ast.Directive @@ ComposeDirective], 72 | resolvers: Seq[EntityResolver[Ctx, Node]]): Schema[Ctx, Val] = { 73 | val resolversMap = resolvers.map(r => r.typename -> r).toMap 74 | val representationsArg = Argument("representations", ListInputType(_Any.__type[Node])) 75 | 76 | val entities = schema.allTypes.values.collect { 77 | case obj: ObjectType[Ctx, _] @unchecked if obj.astDirectives.exists(_.name == "key") => obj 78 | }.toList 79 | 80 | val federationDirectives: List[Directive] = List( 81 | Directives.Key.definition, 82 | Directives.InterfaceObjectDefinition, 83 | Directives.ExtendsDefinition, 84 | Directives.ShareableDefinition, 85 | Directives.InaccessibleDefinition, 86 | Directives.Override.Definition, 87 | Directives.ExternalDefinition, 88 | Directives.Provides.definition, 89 | Directives.Requires.definition, 90 | Directives.Tag.definition 91 | ) 92 | 93 | val importedDirectives: List[Directive] = 94 | if (composeDirectives.nonEmpty) 95 | Directives.ComposeDirective.definition :: federationDirectives 96 | else 97 | federationDirectives 98 | 99 | val federationV2Link = Directives.Link( 100 | url = "https://specs.apollo.dev/federation/v2.3", 101 | `import` = Some(importedDirectives.map(d => Link__Import("@" + d.name)).toVector) 102 | ) 103 | 104 | val addedDirectives: Vector[ast.Directive] = 105 | (federationV2Link :: additionalLinkImports ::: composeDirectives).toVector 106 | 107 | val extendedSchema = schema.copy(astDirectives = addedDirectives) 108 | val sdl = Some(extendedSchema.renderPretty(SchemaFilter.withoutGraphQLBuiltIn)) 109 | 110 | (entities match { 111 | case Nil => 112 | extendedSchema.extend( 113 | ast.Document(Vector(queryType(_service))), 114 | AstSchemaBuilder.resolverBased[Ctx]( 115 | FieldResolver.map("Query" -> Map("_service" -> (_ => _Service(sdl)))), 116 | AdditionalTypes( 117 | _Any.__type[Node], 118 | Link__Import.Type, 119 | _Service.Type, 120 | _FieldSet.Type, 121 | Link__Purpose.Type) 122 | ) 123 | ) 124 | case entities => 125 | extendedSchema.extend( 126 | ast.Document(Vector(queryType(_service, _entities))), 127 | AstSchemaBuilder.resolverBased[Ctx]( 128 | FieldResolver.map( 129 | "Query" -> Map( 130 | "_service" -> (_ => _Service(sdl)), 131 | "_entities" -> (ctx => 132 | ctx.withArgs(representationsArg) { (anys: Seq[_Any[Node]]) => 133 | Action.sequence(anys.map { (any: _Any[Node]) => 134 | val typeName = any.__typename 135 | val resolver = resolversMap.getOrElse( 136 | typeName, 137 | throw new Exception(s"no resolver found for type '$typeName'")) 138 | 139 | any.fields.decode[resolver.Arg](resolver.decoder) match { 140 | case Right(value) => resolver.resolve(value, ctx) 141 | case Left(e) => throw e 142 | } 143 | }) 144 | }) 145 | ) 146 | ), 147 | AdditionalTypes( 148 | _Any.__type[Node], 149 | Link__Import.Type, 150 | _Service.Type, 151 | _Entity(entities), 152 | _FieldSet.Type, 153 | Link__Purpose.Type) 154 | ) 155 | ) 156 | }).copy(directives = Directives.Link.definition :: extendedSchema.directives) 157 | } 158 | 159 | def upgrade[Node](default: InputUnmarshaller[Node]): InputUnmarshaller[Node] = 160 | new InputUnmarshaller[Node] { 161 | 162 | override def getRootMapValue(node: Node, key: String): Option[Node] = 163 | default.getRootMapValue(node, key) 164 | override def isMapNode(node: Node): Boolean = default.isMapNode(node) 165 | override def getMapValue(node: Node, key: String): Option[Node] = 166 | default.getMapValue(node, key) 167 | override def getMapKeys(node: Node): Traversable[String] = default.getMapKeys(node) 168 | 169 | override def isListNode(node: Node): Boolean = default.isListNode(node) 170 | override def getListValue(node: Node): Seq[Node] = default.getListValue(node) 171 | 172 | override def isDefined(node: Node): Boolean = default.isDefined(node) 173 | override def isEnumNode(node: Node): Boolean = default.isEnumNode(node) 174 | override def isVariableNode(node: Node): Boolean = default.isVariableNode(node) 175 | 176 | override def getScalaScalarValue(node: Node): Any = 177 | default.getScalaScalarValue(node) 178 | 179 | override def getVariableName(node: Node): String = default.getVariableName(node) 180 | 181 | override def render(node: Node): String = default.render(node) 182 | 183 | override def isScalarNode(node: Node): Boolean = 184 | default.isMapNode(node) || default.isScalarNode(node) 185 | override def getScalarValue(node: Node): Any = 186 | if (default.isMapNode(node)) { 187 | if (getMapValue(node, "__typename").isDefined) { 188 | new NodeObject[Node] { 189 | 190 | override def __typename: Option[String] = 191 | getMapValue(node, "__typename").map(node => 192 | getScalarValue(node).asInstanceOf[String]) 193 | 194 | override def decode[T](implicit ev: Decoder[Node, T]): Either[Exception, T] = 195 | ev.decode(node) 196 | } 197 | } else { 198 | getMapValue(node, "name") match { 199 | case Some(name) => 200 | getScalaScalarValue(name) match { 201 | case name: String => 202 | getMapValue(node, "as") match { 203 | case Some(as) => 204 | getScalaScalarValue(as) match { 205 | case as: String => 206 | Link__Import_Object(name, Some(as)) 207 | case _ => Link__Import_Object(name) 208 | } 209 | case None => 210 | Link__Import_Object(name) 211 | } 212 | case _ => 213 | default.getScalarValue(node) 214 | } 215 | case None => 216 | default.getScalarValue(node) 217 | } 218 | } 219 | } else default.getScalarValue(node) 220 | } 221 | } 222 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /core/src/test/scala/sangria/federation/v2/FederationSpec.scala: -------------------------------------------------------------------------------- 1 | package sangria.federation.v2 2 | 3 | import io.circe.Json 4 | import io.circe.generic.semiauto._ 5 | import io.circe.parser._ 6 | import org.scalatest.freespec.AsyncFreeSpec 7 | import org.scalatest.matchers.should.Matchers._ 8 | import sangria.execution.deferred.{DeferredResolver, Fetcher, HasId} 9 | import sangria.execution.{Executor, VariableCoercionError} 10 | import sangria.federation._ 11 | import sangria.federation.v2.Directives.Key 12 | import sangria.macros._ 13 | import sangria.parser.QueryParser 14 | import sangria.renderer.{QueryRenderer, SchemaRenderer} 15 | import sangria.schema._ 16 | 17 | import scala.concurrent.Future 18 | import scala.util.Success 19 | 20 | class FederationSpec extends AsyncFreeSpec { 21 | 22 | "federation schema v2" - { 23 | "should respect Apollo specification" - { 24 | "in case no entity is defined" in { 25 | val schema = Federation.extend( 26 | Schema.buildFromAst(graphql""" 27 | schema { 28 | query: Query 29 | } 30 | 31 | type Query { 32 | field: Int 33 | } 34 | """), 35 | Nil 36 | ) 37 | 38 | val expectedSubGraphSchema = Schema 39 | .buildFromAst(graphql""" 40 | schema @link(url: "https://specs.apollo.dev/federation/v2.3", import: ["@key", "@interfaceObject", "@extends", "@shareable", "@inaccessible", "@override", "@external", "@provides", "@requires", "@tag"]) { 41 | query: Query 42 | } 43 | 44 | type Query { 45 | field: Int 46 | _service: _Service! 47 | } 48 | 49 | scalar _FieldSet 50 | 51 | scalar _Any 52 | 53 | scalar link__Import 54 | 55 | enum link__Purpose { 56 | "`SECURITY` features provide metadata necessary to securely resolve fields." 57 | SECURITY 58 | 59 | "`EXECUTION` features provide metadata necessary for operation execution." 60 | EXECUTION 61 | } 62 | 63 | type _Service { 64 | sdl: String 65 | } 66 | 67 | directive @link(url: String!, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA 68 | """) 69 | 70 | withClue(SchemaRenderer.renderSchema(schema)) { 71 | schema should beCompatibleWith(expectedSubGraphSchema) 72 | } 73 | } 74 | 75 | "in case entities are defined" in { 76 | val schema = Federation.extend( 77 | Schema.buildFromAst(graphql""" 78 | schema { 79 | query: Query 80 | } 81 | 82 | type Query { 83 | states: [State] 84 | reviews: [Review] 85 | } 86 | 87 | type State @key(fields: "id") { 88 | id: Int 89 | value: String 90 | } 91 | 92 | type Review @key(fields: "id") { 93 | id: Int 94 | comment: String 95 | } 96 | """), 97 | Nil 98 | ) 99 | 100 | val expectedSubGraphSchema = Schema 101 | .buildFromAst(graphql""" 102 | schema @link(url: "https://specs.apollo.dev/federation/v2.3", import: ["@key", "@interfaceObject", "@extends", "@shareable", "@inaccessible", "@override", "@external", "@provides", "@requires", "@tag"]) { 103 | query: Query 104 | } 105 | 106 | type Query { 107 | states: [State] 108 | reviews: [Review] 109 | _entities(representations: [_Any!]!): [_Entity]! 110 | _service: _Service! 111 | } 112 | 113 | type State @key(fields: "id") { 114 | id: Int 115 | value: String 116 | } 117 | 118 | type Review @key(fields: "id") { 119 | id: Int 120 | comment: String 121 | } 122 | union _Entity = State | Review 123 | 124 | scalar _FieldSet 125 | 126 | scalar _Any 127 | 128 | scalar link__Import 129 | 130 | enum link__Purpose { 131 | "`SECURITY` features provide metadata necessary to securely resolve fields." 132 | SECURITY 133 | 134 | "`EXECUTION` features provide metadata necessary for operation execution." 135 | EXECUTION 136 | } 137 | 138 | type _Service { 139 | sdl: String 140 | } 141 | 142 | directive @link(url: String!, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA 143 | """) 144 | 145 | schema should beCompatibleWith(expectedSubGraphSchema) 146 | } 147 | } 148 | 149 | "_service sdl field" - { 150 | import sangria.marshalling.queryAst.queryAstResultMarshaller 151 | 152 | val Success(query) = QueryParser.parse(""" 153 | query { 154 | _service { 155 | sdl 156 | } 157 | } 158 | """) 159 | 160 | "should not include federation types" - { 161 | "in case no entity is defined" in { 162 | val schema = Federation.extend( 163 | Schema.buildFromAst(graphql""" 164 | schema { 165 | query: Query 166 | } 167 | 168 | type Query { 169 | field: Int 170 | } 171 | """), 172 | Nil 173 | ) 174 | 175 | Executor 176 | .execute(schema, query) 177 | .map(QueryRenderer.renderPretty(_) should be("""{ 178 | | data: { 179 | | _service: { 180 | | sdl: "schema @link(url: \"https://specs.apollo.dev/federation/v2.3\", import: [\"@key\", \"@interfaceObject\", \"@extends\", \"@shareable\", \"@inaccessible\", \"@override\", \"@external\", \"@provides\", \"@requires\", \"@tag\"]) {\n query: Query\n}\n\ntype Query {\n field: Int\n}" 181 | | } 182 | | } 183 | |}""".stripMargin)) 184 | } 185 | 186 | "in case entities are defined" in { 187 | val schema = Federation.extend( 188 | Schema.buildFromAst(graphql""" 189 | schema { 190 | query: Query 191 | } 192 | 193 | type Query { 194 | states: [State] 195 | } 196 | 197 | type State @key(fields: "id") { 198 | id: Int 199 | value: String 200 | } 201 | """), 202 | Nil 203 | ) 204 | 205 | Executor 206 | .execute(schema, query) 207 | .map(QueryRenderer.renderPretty(_) should be("""{ 208 | | data: { 209 | | _service: { 210 | | sdl: "schema @link(url: \"https://specs.apollo.dev/federation/v2.3\", import: [\"@key\", \"@interfaceObject\", \"@extends\", \"@shareable\", \"@inaccessible\", \"@override\", \"@external\", \"@provides\", \"@requires\", \"@tag\"]) {\n query: Query\n}\n\ntype Query {\n states: [State]\n}\n\ntype State @key(fields: \"id\") {\n id: Int\n value: String\n}" 211 | | } 212 | | } 213 | |}""".stripMargin)) 214 | } 215 | } 216 | 217 | "should not filter Sangria built-in types and filter GraphQL built-in types" in { 218 | val schema = Federation.extend( 219 | Schema.buildFromAst(graphql""" 220 | schema { 221 | query: Query 222 | } 223 | 224 | type Query { 225 | foo: Long 226 | bar: Int 227 | } 228 | """), 229 | Nil 230 | ) 231 | 232 | Executor 233 | .execute(schema, query) 234 | .map(QueryRenderer.renderPretty(_) should be("""{ 235 | | data: { 236 | | _service: { 237 | | sdl: "schema @link(url: \"https://specs.apollo.dev/federation/v2.3\", import: [\"@key\", \"@interfaceObject\", \"@extends\", \"@shareable\", \"@inaccessible\", \"@override\", \"@external\", \"@provides\", \"@requires\", \"@tag\"]) {\n query: Query\n}\n\n\"The `Long` scalar type represents non-fractional signed whole numeric values. Long can represent values between -(2^63) and 2^63 - 1.\"\nscalar Long\n\ntype Query {\n foo: Long\n bar: Int\n}" 238 | | } 239 | | } 240 | |}""".stripMargin)) 241 | } 242 | } 243 | 244 | "Apollo gateway queries" - { 245 | 246 | val Success(query) = QueryParser.parse(""" 247 | query FetchState($representations: [_Any!]!) { 248 | _entities(representations: $representations) { 249 | ... on State { 250 | id 251 | value 252 | } 253 | } 254 | } 255 | """) 256 | 257 | import sangria.marshalling.queryAst.queryAstResultMarshaller 258 | 259 | "should succeed on federated unmarshaller" in { 260 | implicit val um = Federation.upgrade(sangria.marshalling.circe.CirceInputUnmarshaller) 261 | val args: Json = parse(""" { "representations": [{ "__typename": "State", "id": 1 }] } """) 262 | .getOrElse(Json.Null) 263 | 264 | Executor 265 | .execute( 266 | FederationSpec.Schema.schema, 267 | query, 268 | variables = args, 269 | deferredResolver = DeferredResolver.fetchers(FederationSpec.Schema.states)) 270 | .map(QueryRenderer.renderPretty(_) should be("""{ 271 | | data: { 272 | | _entities: [{ 273 | | id: 1 274 | | value: "mock state 1" 275 | | }] 276 | | } 277 | |}""".stripMargin)) 278 | } 279 | 280 | "should fail on regular unmarshaller" in { 281 | implicit val um = sangria.marshalling.circe.CirceInputUnmarshaller 282 | val args: Json = parse(""" { "representations": [{ "__typename": "State", "id": 1 }] } """) 283 | .getOrElse(Json.Null) 284 | 285 | recoverToSucceededIf[VariableCoercionError] { 286 | Executor 287 | .execute(FederationSpec.Schema.schema, query, variables = args) 288 | } 289 | } 290 | } 291 | } 292 | } 293 | 294 | object FederationSpec { 295 | object Schema { 296 | // =================== State =================== 297 | // State model 298 | case class State(id: Int, value: String) 299 | 300 | // State GraphQL Model 301 | private val StateType = ObjectType( 302 | "State", 303 | fields[Unit, State]( 304 | Field("id", IntType, resolve = _.value.id), 305 | Field("value", OptionType(StringType), resolve = _.value.value))).withDirective(Key("id")) 306 | 307 | // State fetcher 308 | private implicit val stateId: HasId[State, Int] = _.id 309 | val states: Fetcher[Any, State, State, Int] = Fetcher { (_: Any, ids: Seq[Int]) => 310 | Future.successful(ids.map(id => State(id, s"mock state $id"))) 311 | } 312 | 313 | // State resolver 314 | private case class StateArg(id: Int) 315 | private implicit val stateArgDecoder: Decoder[Json, StateArg] = 316 | deriveDecoder[StateArg].decodeJson(_) 317 | private val stateResolver = EntityResolver[Any, Json, State, StateArg]( 318 | __typeName = StateType.name, 319 | (arg, _) => states.deferOpt(arg.id)) 320 | 321 | // =================== Query =================== 322 | private val Query = ObjectType( 323 | "Query", 324 | fields[Unit, Any]( 325 | Field(name = "states", fieldType = ListType(StateType), resolve = _ => Nil) 326 | ) 327 | ) 328 | 329 | // =================== Schema =================== 330 | val schema: Schema[Any, Any] = Federation.extend( 331 | sangria.schema.Schema(Query), 332 | List(stateResolver) 333 | ) 334 | } 335 | } 336 | -------------------------------------------------------------------------------- /core/src/test/scala/sangria/federation/v1/FederationSpec.scala: -------------------------------------------------------------------------------- 1 | package sangria.federation.v1 2 | 3 | import scala.util.Success 4 | import io.circe.Json 5 | import io.circe.generic.semiauto._ 6 | import io.circe.parser._ 7 | import org.scalatest.freespec.AsyncFreeSpec 8 | import org.scalatest.matchers.should.Matchers._ 9 | import sangria.ast.Document 10 | import sangria.execution.{Executor, VariableCoercionError} 11 | import sangria.federation._ 12 | import sangria.federation.v1.Directives.Key 13 | import sangria.macros._ 14 | import sangria.parser.QueryParser 15 | import sangria.renderer.QueryRenderer 16 | import sangria.schema._ 17 | 18 | class FederationSpec extends AsyncFreeSpec { 19 | 20 | "federation schema v1" - { 21 | "should respect Apollo specification" - { 22 | "in case no entity is defined" in { 23 | val schema = Federation.extend( 24 | Schema.buildFromAst(graphql""" 25 | schema { 26 | query: Query 27 | } 28 | 29 | type Query { 30 | field: Int 31 | } 32 | """), 33 | Nil 34 | ) 35 | 36 | val expectedSubGraphSchema = Schema 37 | .buildFromAst(graphql""" 38 | schema { 39 | query: Query 40 | } 41 | 42 | type Query { 43 | field: Int 44 | _service: _Service! 45 | } 46 | 47 | scalar _FieldSet 48 | 49 | scalar _Any 50 | 51 | type _Service { 52 | sdl: String 53 | } 54 | 55 | directive @extends on INTERFACE | OBJECT 56 | 57 | directive @external on FIELD_DEFINITION 58 | 59 | directive @requires(fields: _FieldSet!) on FIELD_DEFINITION 60 | 61 | directive @provides(fields: _FieldSet!) on FIELD_DEFINITION 62 | 63 | directive @key(fields: _FieldSet!) repeatable on OBJECT | INTERFACE 64 | """) 65 | 66 | schema should beCompatibleWith(expectedSubGraphSchema) 67 | } 68 | 69 | "in case entities are defined" in { 70 | val schema = Federation.extend( 71 | Schema.buildFromAst(graphql""" 72 | schema { 73 | query: Query 74 | } 75 | 76 | type Query { 77 | states: [State] 78 | reviews: [Review] 79 | } 80 | 81 | type State @key(fields: "id") { 82 | id: Int 83 | value: String 84 | } 85 | 86 | type Review @key(fields: "id") { 87 | id: Int 88 | comment: String 89 | } 90 | """), 91 | Nil 92 | ) 93 | 94 | val expectedSubGraphSchema = Schema 95 | .buildFromAst(graphql""" 96 | schema { 97 | query: Query 98 | } 99 | 100 | type Query { 101 | states: [State] 102 | reviews: [Review] 103 | _entities(representations: [_Any!]!): [_Entity]! 104 | _service: _Service! 105 | } 106 | 107 | type State @key(fields: "id") { 108 | id: Int 109 | value: String 110 | } 111 | 112 | type Review @key(fields: "id") { 113 | id: Int 114 | comment: String 115 | } 116 | union _Entity = State | Review 117 | 118 | scalar _FieldSet 119 | 120 | scalar _Any 121 | 122 | type _Service { 123 | sdl: String 124 | } 125 | 126 | directive @extends on INTERFACE | OBJECT 127 | 128 | directive @external on FIELD_DEFINITION 129 | 130 | directive @requires(fields: _FieldSet!) on FIELD_DEFINITION 131 | 132 | directive @provides(fields: _FieldSet!) on FIELD_DEFINITION 133 | 134 | directive @key(fields: _FieldSet!) repeatable on OBJECT | INTERFACE 135 | """) 136 | 137 | schema should beCompatibleWith(expectedSubGraphSchema) 138 | } 139 | } 140 | 141 | "_service sdl field" - { 142 | import sangria.marshalling.queryAst.queryAstResultMarshaller 143 | 144 | val Success(query) = QueryParser.parse(""" 145 | query { 146 | _service { 147 | sdl 148 | } 149 | } 150 | """) 151 | 152 | "should not include federation types" - { 153 | "in case no entity is defined" in { 154 | val schema = Federation.extend( 155 | Schema.buildFromAst(graphql""" 156 | schema { 157 | query: Query 158 | } 159 | 160 | type Query { 161 | field: Int 162 | } 163 | """), 164 | Nil 165 | ) 166 | 167 | Executor 168 | .execute(schema, query) 169 | .map(QueryRenderer.renderPretty(_) should be("""{ 170 | | data: { 171 | | _service: { 172 | | sdl: "type Query {\n field: Int\n}" 173 | | } 174 | | } 175 | |}""".stripMargin)) 176 | } 177 | 178 | "in case entities are defined" in { 179 | val schema = Federation.extend( 180 | Schema.buildFromAst(graphql""" 181 | schema { 182 | query: Query 183 | } 184 | 185 | type Query { 186 | states: [State] 187 | } 188 | 189 | type State @key(fields: "id") { 190 | id: Int 191 | value: String 192 | } 193 | """), 194 | Nil 195 | ) 196 | 197 | Executor 198 | .execute(schema, query) 199 | .map(QueryRenderer.renderPretty(_) should be("""{ 200 | | data: { 201 | | _service: { 202 | | sdl: "type Query {\n states: [State]\n}\n\ntype State @key(fields: \"id\") {\n id: Int\n value: String\n}" 203 | | } 204 | | } 205 | |}""".stripMargin)) 206 | } 207 | } 208 | 209 | "should not filter Sangria built-in types and filter GraphQL built-in types" in { 210 | val schema = Federation.extend( 211 | Schema.buildFromAst(graphql""" 212 | schema { 213 | query: Query 214 | } 215 | 216 | type Query { 217 | foo: Long 218 | bar: Int 219 | } 220 | """), 221 | Nil 222 | ) 223 | 224 | Executor 225 | .execute(schema, query) 226 | .map(QueryRenderer.renderPretty(_) should be("""{ 227 | | data: { 228 | | _service: { 229 | | sdl: "\"The `Long` scalar type represents non-fractional signed whole numeric values. Long can represent values between -(2^63) and 2^63 - 1.\"\nscalar Long\n\ntype Query {\n foo: Long\n bar: Int\n}" 230 | | } 231 | | } 232 | |}""".stripMargin)) 233 | } 234 | } 235 | 236 | "Apollo gateway queries" - { 237 | 238 | val Success(query) = QueryParser.parse(""" 239 | query FetchState($representations: [_Any!]!) { 240 | _entities(representations: $representations) { 241 | ... on State { 242 | id 243 | value 244 | } 245 | ... on Review { 246 | id 247 | value 248 | } 249 | } 250 | } 251 | """) 252 | 253 | val args: Json = parse(""" { "representations": [{ "__typename": "State", "id": 1 }] } """) 254 | .getOrElse(Json.Null) 255 | 256 | import sangria.marshalling.queryAst.queryAstResultMarshaller 257 | 258 | "should succeed on federated unmarshaller" in { 259 | 260 | implicit val um = Federation.upgrade(sangria.marshalling.circe.CirceInputUnmarshaller) 261 | 262 | val args: Json = parse(""" { "representations": [{ "__typename": "State", "id": 1 }] } """) 263 | .getOrElse(Json.Null) 264 | 265 | Executor 266 | .execute(FederationSpec.Schema.schema, query, variables = args) 267 | .map(QueryRenderer.renderPretty(_) should be("""{ 268 | | data: { 269 | | _entities: [{ 270 | | id: 1 271 | | value: "mock state 1" 272 | | }] 273 | | } 274 | |}""".stripMargin)) 275 | } 276 | 277 | "should fail on regular unmarshaller" in { 278 | 279 | implicit val um = sangria.marshalling.circe.CirceInputUnmarshaller 280 | 281 | val args: Json = parse(""" { "representations": [{ "__typename": "State", "id": 1 }] } """) 282 | .getOrElse(Json.Null) 283 | 284 | recoverToSucceededIf[VariableCoercionError] { 285 | Executor 286 | .execute(FederationSpec.Schema.schema, query, variables = args) 287 | } 288 | } 289 | 290 | "should fetch several entities" in { 291 | 292 | implicit val um = Federation.upgrade(sangria.marshalling.circe.CirceInputUnmarshaller) 293 | 294 | val args: Json = parse(""" 295 | { 296 | "representations": [ 297 | { "__typename": "State", "id": 1 }, 298 | { "__typename": "State", "id": 2 }, 299 | { "__typename": "Review", "id": 2 }, 300 | { "__typename": "State", "id": 20 }, 301 | { "__typename": "State", "id": 5 }, 302 | { "__typename": "Review", "id": 1 } 303 | ] 304 | } 305 | """).getOrElse(Json.Null) 306 | 307 | Executor 308 | .execute(FederationSpec.Schema.schema, query, variables = args) 309 | .map(QueryRenderer.renderPretty(_) should be("""{ 310 | | data: { 311 | | _entities: [{ 312 | | id: 1 313 | | value: "mock state 1" 314 | | }, { 315 | | id: 2 316 | | value: "mock state 2" 317 | | }, { 318 | | id: 2 319 | | value: "mock review 2" 320 | | }, { 321 | | id: 20 322 | | value: "mock state 20" 323 | | }, { 324 | | id: 5 325 | | value: "mock state 5" 326 | | }, { 327 | | id: 1 328 | | value: "mock review 1" 329 | | }] 330 | | } 331 | |}""".stripMargin)) 332 | } 333 | } 334 | } 335 | } 336 | 337 | object FederationSpec { 338 | object Schema { 339 | private case class State(id: Int, value: String) 340 | private case class StateArg(id: Int) 341 | 342 | private val StateType = ObjectType( 343 | "State", 344 | fields[Unit, State]( 345 | Field("id", IntType, resolve = _.value.id), 346 | Field("value", OptionType(StringType), resolve = _.value.value))).withDirective(Key("id")) 347 | private implicit val stateArgDecoder: Decoder[Json, StateArg] = 348 | deriveDecoder[StateArg].decodeJson(_) 349 | private val stateResolver = EntityResolver[Any, Json, State, StateArg]( 350 | __typeName = "State", 351 | (arg, _) => Some(State(arg.id, s"mock state ${arg.id}"))) 352 | 353 | private case class Review(id: Int, value: String) 354 | private case class ReviewArg(id: Int) 355 | private val ReviewType = ObjectType( 356 | "Review", 357 | fields[Unit, Review]( 358 | Field("id", IntType, resolve = _.value.id), 359 | Field("value", OptionType(StringType), resolve = _.value.value))).withDirective(Key("id")) 360 | private implicit val reviewArgDecoder: Decoder[Json, ReviewArg] = 361 | deriveDecoder[ReviewArg].decodeJson(_) 362 | private val reviewResolver = EntityResolver[Any, Json, Review, ReviewArg]( 363 | __typeName = "Review", 364 | (arg, _) => Some(Review(arg.id, s"mock review ${arg.id}"))) 365 | 366 | private val Query = ObjectType( 367 | "Query", 368 | fields[Unit, Any]( 369 | Field(name = "states", fieldType = ListType(StateType), resolve = _ => Nil), 370 | Field(name = "reviews", fieldType = ListType(ReviewType), resolve = _ => Nil) 371 | ) 372 | ) 373 | 374 | val schema: Schema[Any, Any] = Federation.extend( 375 | sangria.schema.Schema(Query), 376 | stateResolver :: reviewResolver :: Nil 377 | ) 378 | } 379 | } 380 | -------------------------------------------------------------------------------- /core/src/test/scala/sangria/federation/v2/ResolverSpec.scala: -------------------------------------------------------------------------------- 1 | package sangria.federation.v2 2 | 3 | import io.circe.Json 4 | import io.circe.generic.semiauto.deriveDecoder 5 | import io.circe.parser.parse 6 | import org.scalatest.matchers.should.Matchers 7 | import org.scalatest.wordspec.AnyWordSpec 8 | import sangria.ast 9 | import sangria.execution.Executor 10 | import sangria.execution.deferred.{Deferred, DeferredResolver, Fetcher} 11 | import sangria.federation.FutureAwaits.await 12 | import sangria.federation.fixtures.TestApp 13 | import sangria.marshalling.InputUnmarshaller 14 | import sangria.marshalling.queryAst.queryAstResultMarshaller 15 | import sangria.parser.QueryParser 16 | import sangria.renderer.QueryRenderer 17 | import sangria.schema._ 18 | 19 | import scala.concurrent.{ExecutionContext, Future} 20 | import scala.util.Success 21 | 22 | class ResolverSpec extends AnyWordSpec with Matchers { 23 | import ResolverSpec._ 24 | "Resolver" should { 25 | "fetch several entities of same type in one call using DeferredValue" in FetchSeveralStatesInOneCall { 26 | () => 27 | case class DeferredState(id: Int) extends Deferred[Option[TestApp.State]] 28 | 29 | val deferredResolver = new DeferredResolver[TestApp.Context] { 30 | override def resolve( 31 | deferred: Vector[Deferred[Any]], 32 | context: TestApp.Context, 33 | queryState: Any)(implicit ec: ExecutionContext): Vector[Future[Any]] = { 34 | val ids = deferred.collect { case DeferredState(id) => id } 35 | val states = context.db.statesByIds(ids) 36 | ids.map(id => states.map(_.find(_.id == id))) 37 | } 38 | } 39 | 40 | case class StateArg(id: Int) 41 | implicit val stateArgDecoder: Decoder[Json, StateArg] = 42 | deriveDecoder[StateArg].decodeJson(_) 43 | val stateResolver = EntityResolver[TestApp.Context, Json, TestApp.State, StateArg]( 44 | __typeName = TestApp.StateType.name, 45 | (arg, _) => DeferredValue(DeferredState(arg.id)) 46 | ) 47 | 48 | (stateResolver, deferredResolver) 49 | } 50 | 51 | "fetch several entities of same type in one call using Fetcher" in FetchSeveralStatesInOneCall { 52 | () => 53 | val states = Fetcher { (ctx: TestApp.Context, ids: Seq[Int]) => 54 | ctx.db.statesByIds(ids) 55 | } 56 | 57 | case class StateArg(id: Int) 58 | implicit val stateArgDecoder: Decoder[Json, StateArg] = 59 | deriveDecoder[StateArg].decodeJson(_) 60 | val stateResolver = EntityResolver[TestApp.Context, Json, TestApp.State, StateArg]( 61 | __typeName = TestApp.StateType.name, 62 | (arg, _) => states.deferOpt(arg.id) 63 | ) 64 | 65 | (stateResolver, DeferredResolver.fetchers(states)) 66 | } 67 | 68 | "fetch several entities of different types in one call" in { 69 | val testApp = AppWithResolvers() 70 | 71 | val args: Json = parse(""" 72 | { 73 | "representations": [ 74 | { "__typename": "State", "id": 1 }, 75 | { "__typename": "State", "id": 2 }, 76 | { "__typename": "Review", "id": 2 }, 77 | { "__typename": "State", "id": 20 }, 78 | { "__typename": "State", "id": 5 }, 79 | { "__typename": "Review", "id": 1 } 80 | ] 81 | } 82 | """).getOrElse(Json.Null) 83 | 84 | val result = testApp.execute(fetchStateAndReview, args) 85 | 86 | QueryRenderer.renderPretty(result) should be("""{ 87 | | data: { 88 | | _entities: [{ 89 | | id: 1 90 | | value: "mock state 1" 91 | | }, { 92 | | id: 2 93 | | value: "mock state 2" 94 | | }, { 95 | | id: 2 96 | | value: "mock review 2" 97 | | }, { 98 | | id: 20 99 | | value: "mock state 20" 100 | | }, { 101 | | id: 5 102 | | value: "mock state 5" 103 | | }, { 104 | | id: 1 105 | | value: "mock review 1" 106 | | }] 107 | | } 108 | |}""".stripMargin) 109 | 110 | testApp.testApp.db.stateDbCalled.get() should be(1) 111 | testApp.testApp.db.reviewDbCalled.get() should be(1) 112 | } 113 | 114 | "handles non found entities" in { 115 | val testApp = AppWithResolvers() 116 | 117 | val args: Json = parse(s""" 118 | { 119 | "representations": [ 120 | { "__typename": "State", "id": 1 }, 121 | { "__typename": "State", "id": ${TestApp.missingStateId} }, 122 | { "__typename": "State", "id": 20 } 123 | ] 124 | } 125 | """).getOrElse(Json.Null) 126 | 127 | val result = testApp.execute(fetchStateAndReview, args) 128 | 129 | QueryRenderer.renderPretty(result) should be("""{ 130 | | data: { 131 | | _entities: [{ 132 | | id: 1 133 | | value: "mock state 1" 134 | | }, null, { 135 | | id: 20 136 | | value: "mock state 20" 137 | | }] 138 | | } 139 | |}""".stripMargin) 140 | 141 | testApp.testApp.db.stateDbCalled.get() should be(1) 142 | testApp.testApp.db.reviewDbCalled.get() should be(0) 143 | } 144 | 145 | "handles entities using same arg" in { 146 | val testApp = new TestApp() 147 | 148 | val states = Fetcher { (ctx: TestApp.Context, ids: Seq[Int]) => 149 | ctx.db.statesByIds(ids) 150 | } 151 | 152 | case class IntArg(id: Int) 153 | implicit val intArgDecoder: Decoder[Json, IntArg] = 154 | deriveDecoder[IntArg].decodeJson(_) 155 | val stateResolver = EntityResolver[TestApp.Context, Json, TestApp.State, IntArg]( 156 | __typeName = TestApp.StateType.name, 157 | (arg, _) => states.deferOpt(arg.id) 158 | ) 159 | 160 | val reviews = Fetcher { (ctx: TestApp.Context, ids: Seq[Int]) => 161 | ctx.db.reviewsByIds(ids) 162 | } 163 | 164 | val reviewResolver = EntityResolver[TestApp.Context, Json, TestApp.Review, IntArg]( 165 | __typeName = TestApp.ReviewType.name, 166 | (arg, _) => reviews.deferOpt(arg.id) 167 | ) 168 | 169 | val schema: Schema[TestApp.Context, Any] = 170 | Federation.extend(TestApp.schema, List(stateResolver, reviewResolver)) 171 | 172 | implicit val um = Federation.upgrade(sangria.marshalling.circe.CirceInputUnmarshaller) 173 | val variables: Json = parse(""" 174 | { 175 | "representations": [ 176 | { "__typename": "Review", "id": 1 }, 177 | { "__typename": "State", "id": 1 }, 178 | { "__typename": "State", "id": 2 } 179 | ] 180 | } 181 | """).getOrElse(Json.Null) 182 | 183 | import ExecutionContext.Implicits.global 184 | val result = await( 185 | Executor.execute( 186 | schema, 187 | fetchStateAndReview, 188 | userContext = testApp.ctx, 189 | variables = variables, 190 | deferredResolver = DeferredResolver.fetchers(states, reviews))) 191 | 192 | QueryRenderer.renderPretty(result) should be("""{ 193 | | data: { 194 | | _entities: [{ 195 | | id: 1 196 | | value: "mock review 1" 197 | | }, { 198 | | id: 1 199 | | value: "mock state 1" 200 | | }, { 201 | | id: 2 202 | | value: "mock state 2" 203 | | }] 204 | | } 205 | |}""".stripMargin) 206 | } 207 | 208 | "handles non-parsable arguments" in { 209 | val testApp = AppWithResolvers() 210 | 211 | val args: Json = parse(s""" 212 | { 213 | "representations": [ 214 | { "__typename": "State", "id": 1 }, 215 | { "__typename": "State", "id": "bla bla" } 216 | ] 217 | } 218 | """).getOrElse(Json.Null) 219 | 220 | val result = testApp.execute(fetchStateAndReview, args) 221 | 222 | QueryRenderer.renderPretty(result) should be("""{ 223 | | data: null 224 | | errors: [{ 225 | | message: "Internal server error" 226 | | path: ["_entities"] 227 | | locations: [{ 228 | | line: 3 229 | | column: 8 230 | | }] 231 | | }] 232 | |}""".stripMargin) 233 | 234 | testApp.testApp.db.stateDbCalled.get() should be(0) 235 | testApp.testApp.db.reviewDbCalled.get() should be(0) 236 | } 237 | } 238 | } 239 | 240 | object ResolverSpec { 241 | 242 | val Success(fetchStateAndReview) = QueryParser.parse(""" 243 | query FetchState($representations: [_Any!]!) { 244 | _entities(representations: $representations) { 245 | ... on State { 246 | id 247 | value 248 | } 249 | ... on Review { 250 | id 251 | value 252 | } 253 | } 254 | } 255 | """) 256 | 257 | case class FetchSeveralStatesInOneCall( 258 | entityAndDeferredResolverF: () => ( 259 | EntityResolver[TestApp.Context, Json], 260 | DeferredResolver[TestApp.Context])) 261 | extends Matchers { 262 | val testApp = new TestApp() 263 | val (stateResolver, deferredResolver) = entityAndDeferredResolverF() 264 | 265 | val schema: Schema[TestApp.Context, Any] = 266 | Federation.extend(TestApp.schema, List(stateResolver)) 267 | 268 | val Success(query) = QueryParser.parse(""" 269 | query FetchState($representations: [_Any!]!) { 270 | _entities(representations: $representations) { 271 | ... on State { id, value } 272 | } 273 | } 274 | """) 275 | 276 | val args: Json = parse(""" 277 | { 278 | "representations": [ 279 | { "__typename": "State", "id": 1 }, 280 | { "__typename": "State", "id": 2 }, 281 | { "__typename": "State", "id": 20 }, 282 | { "__typename": "State", "id": 5 } 283 | ] 284 | } 285 | """).getOrElse(Json.Null) 286 | 287 | implicit val um: InputUnmarshaller[Json] = 288 | Federation.upgrade(sangria.marshalling.circe.CirceInputUnmarshaller) 289 | import ExecutionContext.Implicits.global 290 | val result: ast.Value = await( 291 | Executor.execute( 292 | schema, 293 | query, 294 | userContext = testApp.ctx, 295 | variables = args, 296 | deferredResolver = deferredResolver)) 297 | 298 | QueryRenderer.renderPretty(result) should be("""{ 299 | | data: { 300 | | _entities: [{ 301 | | id: 1 302 | | value: "mock state 1" 303 | | }, { 304 | | id: 2 305 | | value: "mock state 2" 306 | | }, { 307 | | id: 20 308 | | value: "mock state 20" 309 | | }, { 310 | | id: 5 311 | | value: "mock state 5" 312 | | }] 313 | | } 314 | |}""".stripMargin) 315 | testApp.db.stateDbCalled.get() should be(1) 316 | } 317 | 318 | case class AppWithResolvers() extends Matchers { 319 | val testApp = new TestApp() 320 | 321 | val states = Fetcher { (ctx: TestApp.Context, ids: Seq[Int]) => 322 | ctx.db.statesByIds(ids) 323 | } 324 | 325 | case class StateArg(id: Int) 326 | implicit val stateArgDecoder: Decoder[Json, StateArg] = 327 | deriveDecoder[StateArg].decodeJson(_) 328 | val stateResolver = EntityResolver[TestApp.Context, Json, TestApp.State, StateArg]( 329 | __typeName = TestApp.StateType.name, 330 | (arg, _) => states.deferOpt(arg.id) 331 | ) 332 | 333 | val reviews = Fetcher { (ctx: TestApp.Context, ids: Seq[Int]) => 334 | ctx.db.reviewsByIds(ids) 335 | } 336 | 337 | case class ReviewArg(id: Int) 338 | implicit val reviewArgDecoder: Decoder[Json, ReviewArg] = 339 | deriveDecoder[ReviewArg].decodeJson(_) 340 | val reviewResolver = EntityResolver[TestApp.Context, Json, TestApp.Review, ReviewArg]( 341 | __typeName = TestApp.ReviewType.name, 342 | (arg, _) => reviews.deferOpt(arg.id) 343 | ) 344 | 345 | val schema: Schema[TestApp.Context, Any] = 346 | Federation.extend(TestApp.schema, List(stateResolver, reviewResolver)) 347 | 348 | implicit val um: InputUnmarshaller[Json] = 349 | Federation.upgrade(sangria.marshalling.circe.CirceInputUnmarshaller) 350 | 351 | def execute[Input](query: ast.Document, variables: Json): ast.Value = { 352 | import ExecutionContext.Implicits.global 353 | await( 354 | Executor.execute( 355 | schema, 356 | query, 357 | userContext = testApp.ctx, 358 | variables = variables, 359 | deferredResolver = DeferredResolver.fetchers(states, reviews))) 360 | } 361 | 362 | } 363 | } 364 | -------------------------------------------------------------------------------- /core/src/main/protobuf/reports.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | 4 | import "google/protobuf/timestamp.proto"; 5 | 6 | message Trace { 7 | message CachePolicy { 8 | enum Scope { 9 | UNKNOWN = 0; 10 | PUBLIC = 1; 11 | PRIVATE = 2; 12 | } 13 | 14 | Scope scope = 1; 15 | int64 max_age_ns = 2; // use 0 for absent, -1 for 0 16 | } 17 | 18 | message Details { 19 | // The variables associated with this query (unless the reporting agent is 20 | // configured to keep them all private). Values are JSON: ie, strings are 21 | // enclosed in double quotes, etc. The value of a private variable is 22 | // the empty string. 23 | map variables_json = 4; 24 | 25 | 26 | // This is deprecated and only used for legacy applications 27 | // don't include this in traces inside a FullTracesReport; the operation 28 | // name for these traces comes from the key of the traces_per_query map. 29 | string operation_name = 3; 30 | } 31 | 32 | message Error { 33 | string message = 1; // required 34 | repeated Location location = 2; 35 | uint64 time_ns = 3; 36 | string json = 4; 37 | } 38 | 39 | message HTTP { 40 | message Values { 41 | repeated string value = 1; 42 | } 43 | 44 | enum Method { 45 | UNKNOWN = 0; 46 | OPTIONS = 1; 47 | GET = 2; 48 | HEAD = 3; 49 | POST = 4; 50 | PUT = 5; 51 | DELETE = 6; 52 | TRACE = 7; 53 | CONNECT = 8; 54 | PATCH = 9; 55 | } 56 | Method method = 1; 57 | string host = 2; 58 | string path = 3; 59 | 60 | // Should exclude manual blacklist ("Auth" by default) 61 | map request_headers = 4; 62 | map response_headers = 5; 63 | 64 | uint32 status_code = 6; 65 | 66 | bool secure = 8; // TLS was used 67 | string protocol = 9; // by convention "HTTP/1.0", "HTTP/1.1", "HTTP/2" or "h2" 68 | } 69 | 70 | message Location { 71 | uint32 line = 1; 72 | uint32 column = 2; 73 | } 74 | 75 | // We store information on each resolver execution as a Node on a tree. 76 | // The structure of the tree corresponds to the structure of the GraphQL 77 | // response; it does not indicate the order in which resolvers were 78 | // invoked. Note that nodes representing indexes (and the root node) 79 | // don't contain all Node fields (eg types and times). 80 | message Node { 81 | // The name of the field (for Nodes representing a resolver call) or the 82 | // index in a list (for intermediate Nodes representing elements of a list). 83 | // field_name is the name of the field as it appears in the GraphQL 84 | // response: ie, it may be an alias. (In that case, the original_field_name 85 | // field holds the actual field name from the schema.) In any context where 86 | // we're building up a path, we use the response_name rather than the 87 | // original_field_name. 88 | oneof id { 89 | string response_name = 1; 90 | uint32 index = 2; 91 | } 92 | 93 | string original_field_name = 14; 94 | 95 | // The field's return type; e.g. "String!" for User.email:String! 96 | string type = 3; 97 | 98 | // The field's parent type; e.g. "User" for User.email:String! 99 | string parent_type = 13; 100 | 101 | CachePolicy cache_policy = 5; 102 | 103 | // relative to the trace's start_time, in ns 104 | uint64 start_time = 8; 105 | // relative to the trace's start_time, in ns 106 | uint64 end_time = 9; 107 | 108 | repeated Error error = 11; 109 | repeated Node child = 12; 110 | 111 | reserved 4; 112 | } 113 | 114 | // represents a node in the query plan, under which there is a trace tree for that service fetch. 115 | // In particular, each fetch node represents a call to an implementing service, and calls to implementing 116 | // services may not be unique. See https://github.com/apollographql/apollo-server/blob/main/packages/apollo-gateway/src/QueryPlan.ts 117 | // for more information and details. 118 | message QueryPlanNode { 119 | // This represents a set of nodes to be executed sequentially by the Gateway executor 120 | message SequenceNode { 121 | repeated QueryPlanNode nodes = 1; 122 | } 123 | // This represents a set of nodes to be executed in parallel by the Gateway executor 124 | message ParallelNode { 125 | repeated QueryPlanNode nodes = 1; 126 | } 127 | // This represents a node to send an operation to an implementing service 128 | message FetchNode { 129 | // XXX When we want to include more details about the sub-operation that was 130 | // executed against this service, we should include that here in each fetch node. 131 | // This might include an operation signature, requires directive, reference resolutions, etc. 132 | string service_name = 1; 133 | 134 | bool trace_parsing_failed = 2; 135 | 136 | // This Trace only contains start_time, end_time, duration_ns, and root; 137 | // all timings were calculated **on the federated service**, and clock skew 138 | // will be handled by the ingress server. 139 | Trace trace = 3; 140 | 141 | // relative to the outer trace's start_time, in ns, measured in the gateway. 142 | uint64 sent_time_offset = 4; 143 | 144 | // Wallclock times measured in the gateway for when this operation was 145 | // sent and received. 146 | google.protobuf.Timestamp sent_time = 5; 147 | google.protobuf.Timestamp received_time = 6; 148 | } 149 | 150 | // This node represents a way to reach into the response path and attach related entities. 151 | // XXX Flatten is really not the right name and this node may be renamed in the query planner. 152 | message FlattenNode { 153 | repeated ResponsePathElement response_path = 1; 154 | QueryPlanNode node = 2; 155 | } 156 | message ResponsePathElement { 157 | oneof id { 158 | string field_name = 1; 159 | uint32 index = 2; 160 | } 161 | } 162 | oneof node { 163 | SequenceNode sequence = 1; 164 | ParallelNode parallel = 2; 165 | FetchNode fetch = 3; 166 | FlattenNode flatten = 4; 167 | } 168 | } 169 | 170 | // Wallclock time when the trace began. 171 | google.protobuf.Timestamp start_time = 4; // required 172 | // Wallclock time when the trace ended. 173 | google.protobuf.Timestamp end_time = 3; // required 174 | // High precision duration of the trace; may not equal end_time-start_time 175 | // (eg, if your machine's clock changed during the trace). 176 | uint64 duration_ns = 11; // required 177 | // A tree containing information about all resolvers run directly by this 178 | // service, including errors. 179 | Node root = 14; 180 | 181 | // ------------------------------------------------------------------------- 182 | // Fields below this line are *not* included in federated traces (the traces 183 | // sent from federated services to the gateway). 184 | 185 | // In addition to details.raw_query, we include a "signature" of the query, 186 | // which can be normalized: for example, you may want to discard aliases, drop 187 | // unused operations and fragments, sort fields, etc. The most important thing 188 | // here is that the signature match the signature in StatsReports. In 189 | // StatsReports signatures show up as the key in the per_query map (with the 190 | // operation name prepended). The signature should be a valid GraphQL query. 191 | // All traces must have a signature; if this Trace is in a FullTracesReport 192 | // that signature is in the key of traces_per_query rather than in this field. 193 | // Engineproxy provides the signature in legacy_signature_needs_resigning 194 | // instead. 195 | string signature = 19; 196 | 197 | // Optional: when GraphQL parsing or validation against the GraphQL schema fails, these fields 198 | // can include reference to the operation being sent for users to dig into the set of operations 199 | // that are failing validation. 200 | string unexecutedOperationBody = 27; 201 | string unexecutedOperationName = 28; 202 | 203 | Details details = 6; 204 | 205 | string client_name = 7; 206 | string client_version = 8; 207 | 208 | HTTP http = 10; 209 | 210 | CachePolicy cache_policy = 18; 211 | 212 | // If this Trace was created by a gateway, this is the query plan, including 213 | // sub-Traces for federated services. Note that the 'root' tree on the 214 | // top-level Trace won't contain any resolvers (though it could contain errors 215 | // that occurred in the gateway itself). 216 | QueryPlanNode query_plan = 26; 217 | 218 | // Was this response served from a full query response cache? (In that case 219 | // the node tree will have no resolvers.) 220 | bool full_query_cache_hit = 20; 221 | 222 | // Was this query specified successfully as a persisted query hash? 223 | bool persisted_query_hit = 21; 224 | // Did this query contain both a full query string and a persisted query hash? 225 | // (This typically means that a previous request was rejected as an unknown 226 | // persisted query.) 227 | bool persisted_query_register = 22; 228 | 229 | // Was this operation registered and a part of the safelist? 230 | bool registered_operation = 24; 231 | 232 | // Was this operation forbidden due to lack of safelisting? 233 | bool forbidden_operation = 25; 234 | 235 | // Some servers don't do field-level instrumentation for every request and assign 236 | // each request a "weight" for each request that they do instrument. When this 237 | // trace is aggregated into field usage stats, it should count as this value 238 | // towards the estimated_execution_count rather than just 1. This value should 239 | // typically be at least 1. 240 | // 241 | // 0 is treated as 1 for backwards compatibility. 242 | double field_execution_weight = 31; 243 | 244 | 245 | 246 | // removed: Node parse = 12; Node validate = 13; 247 | // Id128 server_id = 1; Id128 client_id = 2; 248 | // String client_reference_id = 23; String client_address = 9; 249 | reserved 1, 2, 9, 12, 13, 23; 250 | } 251 | 252 | // The `service` value embedded within the header key is not guaranteed to contain an actual service, 253 | // and, in most cases, the service information is trusted to come from upstream processing. If the 254 | // service _is_ specified in this header, then it is checked to match the context that is reporting it. 255 | // Otherwise, the service information is deduced from the token context of the reporter and then sent 256 | // along via other mechanisms (in Kafka, the `ReportKafkaKey). The other information (hostname, 257 | // agent_version, etc.) is sent by the Apollo Engine Reporting agent, but we do not currently save that 258 | // information to any of our persistent storage. 259 | message ReportHeader { 260 | // eg "mygraph@myvariant" 261 | string graph_ref = 12; 262 | 263 | // eg "host-01.example.com" 264 | string hostname = 5; 265 | 266 | // eg "engineproxy 0.1.0" 267 | string agent_version = 6; // required 268 | // eg "prod-4279-20160804T065423Z-5-g3cf0aa8" (taken from `git describe --tags`) 269 | string service_version = 7; 270 | // eg "node v4.6.0" 271 | string runtime_version = 8; 272 | // eg "Linux box 4.6.5-1-ec2 #1 SMP Mon Aug 1 02:31:38 PDT 2016 x86_64 GNU/Linux" 273 | string uname = 9; 274 | // An id that is used to represent the schema to Apollo Graph Manager 275 | // Using this in place of what used to be schema_hash, since that is no longer 276 | // attached to a schema in the backend. 277 | string executable_schema_id = 11; 278 | 279 | reserved 3; // removed string service = 3; 280 | } 281 | 282 | message PathErrorStats { 283 | map children = 1; 284 | uint64 errors_count = 4; 285 | uint64 requests_with_errors_count = 5; 286 | } 287 | 288 | message QueryLatencyStats { 289 | repeated sint64 latency_count = 13; 290 | uint64 request_count = 2; 291 | uint64 cache_hits = 3; 292 | uint64 persisted_query_hits = 4; 293 | uint64 persisted_query_misses = 5; 294 | repeated sint64 cache_latency_count = 14; 295 | PathErrorStats root_error_stats = 7; 296 | uint64 requests_with_errors_count = 8; 297 | repeated sint64 public_cache_ttl_count = 15; 298 | repeated sint64 private_cache_ttl_count = 16; 299 | uint64 registered_operation_count = 11; 300 | uint64 forbidden_operation_count = 12; 301 | // The number of requests that were executed without field-level 302 | // instrumentation (and thus do not contribute to `observed_execution_count` 303 | // fields on this message's cousin-twice-removed FieldStats). 304 | uint64 requests_without_field_instrumentation = 17; 305 | // 1, 6, 9, and 10 were old int64 histograms 306 | reserved 1, 6, 9, 10; 307 | } 308 | 309 | message StatsContext { 310 | // string client_reference_id = 1; 311 | reserved 1; 312 | string client_name = 2; 313 | string client_version = 3; 314 | } 315 | 316 | message ContextualizedQueryLatencyStats { 317 | QueryLatencyStats query_latency_stats = 1; 318 | StatsContext context = 2; 319 | } 320 | 321 | message ContextualizedTypeStats { 322 | StatsContext context = 1; 323 | map per_type_stat = 2; 324 | } 325 | 326 | message FieldStat { 327 | string return_type = 3; // required; eg "String!" for User.email:String! 328 | // Number of errors whose path is this field. Note that we assume that error 329 | // tracking does *not* require field-level instrumentation so this *will* 330 | // include errors from requests that don't contribute to the 331 | // `observed_execution_count` field (and does not need to be scaled by 332 | // field_execution_weight). 333 | uint64 errors_count = 4; 334 | // Number of times that the resolver for this field is directly observed being 335 | // executed. 336 | uint64 observed_execution_count = 5; 337 | // Same as `count` but potentially scaled upwards if the server was only 338 | // performing field-level instrumentation on a sampling of operations. For 339 | // example, if the server randomly instruments 1% of requests for this 340 | // operation, this number will be 100 times greater than 341 | // `observed_execution_count`. (When aggregating a Trace into FieldStats, 342 | // this number goes up by the trace's `field_execution_weight` for each 343 | // observed field execution, while `observed_execution_count` above goes 344 | // up by 1.) 345 | uint64 estimated_execution_count = 10; 346 | // Number of times the resolver for this field is executed that resulted in 347 | // at least one error. "Request" is a misnomer here as this corresponds to 348 | // resolver calls, not overall operations. Like `errors_count` above, this 349 | // includes all requests rather than just requests with field-level 350 | // instrumentation. 351 | uint64 requests_with_errors_count = 6; 352 | // Duration histogram for the latency of this field. Note that it is scaled in 353 | // the same way as estimated_execution_count so its "total count" might be 354 | // greater than `observed_execution_count` and may not exactly equal 355 | // `estimated_execution_count` due to rounding. 356 | repeated sint64 latency_count = 9; 357 | reserved 1, 2, 7, 8; 358 | } 359 | 360 | message TypeStat { 361 | // Key is (eg) "email" for User.email:String! 362 | map per_field_stat = 3; 363 | reserved 1, 2; 364 | } 365 | 366 | message ReferencedFieldsForType { 367 | // Contains (eg) "email" for User.email:String! 368 | repeated string field_names = 1; 369 | // True if this type is an interface. 370 | bool is_interface = 2; 371 | } 372 | 373 | 374 | 375 | // This is the top-level message used by the new traces ingress. This 376 | // is designed for the apollo-engine-reporting TypeScript agent and will 377 | // eventually be documented as a public ingress API. This message consists 378 | // solely of traces; the equivalent of the StatsReport is automatically 379 | // generated server-side from this message. Agent should either send a trace or include it in the stats 380 | // for every request in this report. Generally, buffering up until a large 381 | // size has been reached (say, 4MB) or 5-10 seconds has passed is appropriate. 382 | // This message used to be know as FullTracesReport, but got renamed since it isn't just for traces anymore 383 | message Report { 384 | ReportHeader header = 1; 385 | 386 | // key is statsReportKey (# operationName\nsignature) Note that the nested 387 | // traces will *not* have a signature or details.operationName (because the 388 | // key is adequate). 389 | // 390 | // We also assume that traces don't have 391 | // legacy_per_query_implicit_operation_name, and we don't require them to have 392 | // details.raw_query (which would consume a lot of space and has privacy/data 393 | // access issues, and isn't currently exposed by our app anyway). 394 | map traces_per_query = 5; 395 | 396 | // This is the time that the requests in this trace are considered to have taken place 397 | // If this field is not present the max of the end_time of each trace will be used instead. 398 | // If there are no traces and no end_time present the report will not be able to be processed. 399 | // Note: This will override the end_time from traces. 400 | google.protobuf.Timestamp end_time = 2; // required if no traces in this message 401 | 402 | // Total number of operations processed during this period. 403 | uint64 operation_count = 6; 404 | } 405 | 406 | message ContextualizedStats { 407 | StatsContext context = 1; 408 | QueryLatencyStats query_latency_stats = 2; 409 | // Key is type name. This structure provides data for the count and latency of individual 410 | // field executions and thus only reflects operations for which field-level tracing occurred. 411 | map per_type_stat = 3; 412 | 413 | } 414 | 415 | // A sequence of traces and stats. An individual operation should either be described as a trace 416 | // or as part of stats, but not both. 417 | message TracesAndStats { 418 | repeated Trace trace = 1; 419 | repeated ContextualizedStats stats_with_context = 2; 420 | // This describes the fields referenced in the operation. Note that this may 421 | // include fields that don't show up in FieldStats (due to being interface fields, 422 | // being nested under null fields or empty lists or non-matching fragments or 423 | // `@include` or `@skip`, etc). It also may be missing fields that show up in FieldStats 424 | // (as FieldStats will include the concrete object type for fields referenced 425 | // via an interface type). 426 | map referenced_fields_by_type = 4; 427 | // This field is used to validate that the algorithm used to construct `stats_with_context` 428 | // matches similar algorithms in Apollo's servers. It is otherwise ignored and should not 429 | // be included in reports. 430 | repeated Trace internal_traces_contributing_to_stats = 3; 431 | } 432 | --------------------------------------------------------------------------------