From 01d3e169af3dd0f5a444fce952268dd4b762ed6b Mon Sep 17 00:00:00 2001 From: Dariusz Kuc <9501705+dariuszkuc@users.noreply.github.com> Date: Tue, 1 Nov 2022 09:14:31 -0500 Subject: [PATCH] graphql-go example integration (#255) Example `graphql-go` integration that is using https://github.com/dariuszkuc/graphql/releases/tag/v0.9.0-federation NOTE: this is a temporary solution while we are waiting for `graphql-go` PRs to get merged ([651](https://github.com/graphql-go/graphql/pull/651), [652](https://github.com/graphql-go/graphql/pull/652) and [653](https://github.com/graphql-go/graphql/pull/653)). Resolves: https://github.com/apollographql/apollo-federation-subgraph-compatibility/issues/74 --- .../workflows/test-subgraph-graphql-go.yaml | 14 + README.md | 1 + implementations/graphql-go/Dockerfile | 9 + .../graphql-go/docker-compose.yaml | 6 + implementations/graphql-go/go.mod | 10 + implementations/graphql-go/go.sum | 4 + implementations/graphql-go/metadata.yaml | 3 + implementations/graphql-go/model/models.go | 47 +++ implementations/graphql-go/products.graphql | 56 ++++ implementations/graphql-go/resolver/data.go | 78 +++++ .../graphql-go/resolver/resolvers.go | 146 +++++++++ implementations/graphql-go/server.go | 300 ++++++++++++++++++ 12 files changed, 674 insertions(+) create mode 100644 .github/workflows/test-subgraph-graphql-go.yaml create mode 100644 implementations/graphql-go/Dockerfile create mode 100644 implementations/graphql-go/docker-compose.yaml create mode 100644 implementations/graphql-go/go.mod create mode 100644 implementations/graphql-go/go.sum create mode 100644 implementations/graphql-go/metadata.yaml create mode 100644 implementations/graphql-go/model/models.go create mode 100644 implementations/graphql-go/products.graphql create mode 100644 implementations/graphql-go/resolver/data.go create mode 100644 implementations/graphql-go/resolver/resolvers.go create mode 100644 implementations/graphql-go/server.go diff --git a/.github/workflows/test-subgraph-graphql-go.yaml b/.github/workflows/test-subgraph-graphql-go.yaml new file mode 100644 index 000000000..875cb83ef --- /dev/null +++ b/.github/workflows/test-subgraph-graphql-go.yaml @@ -0,0 +1,14 @@ +name: GraphQL Go Test + +on: + pull_request: + branches: + - main + paths: + - 'implementations/graphql-go/**' + +jobs: + compatibility: + uses: ./.github/workflows/test-subgraph.yaml + with: + library: "graphql-go" \ No newline at end of file diff --git a/README.md b/README.md index bd581d5bd..51e0424a1 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,7 @@ The following open-source GraphQL server libraries and hosted subgraphs provide LibraryFederation 1 SupportFederation 2 Support +GraphQL Go
_service🟢
@key (single)🟢
@key (multi)🟢
@key (composite)🟢
repeatable @key🟢
@requires🟢
@provides🟢
federated tracing🔲
@link🟢
@shareable🟢
@tag🟢
@override🟢
@inaccessible🟢
gqlgen
_service🟢
@key (single)🟢
@key (multi)🟢
@key (composite)🟢
repeatable @key🟢
@requires🔲
@provides🟢
federated tracing🟢
@link🟢
@shareable🟢
@tag🟢
@override🟢
@inaccessible🟢
diff --git a/implementations/graphql-go/Dockerfile b/implementations/graphql-go/Dockerfile new file mode 100644 index 000000000..19445b263 --- /dev/null +++ b/implementations/graphql-go/Dockerfile @@ -0,0 +1,9 @@ +FROM golang:1.19 + +WORKDIR /go/src/server +COPY . . + +RUN go get -d -v ./... +RUN go install -v ./... + +CMD go run server.go diff --git a/implementations/graphql-go/docker-compose.yaml b/implementations/graphql-go/docker-compose.yaml new file mode 100644 index 000000000..0d5a0d5ea --- /dev/null +++ b/implementations/graphql-go/docker-compose.yaml @@ -0,0 +1,6 @@ +services: + products: + # must be relative to the root of the project + build: implementations/graphql-go + ports: + - 4001:4001 diff --git a/implementations/graphql-go/go.mod b/implementations/graphql-go/go.mod new file mode 100644 index 000000000..fcab4f109 --- /dev/null +++ b/implementations/graphql-go/go.mod @@ -0,0 +1,10 @@ +module graphql-go-compatibility + +go 1.19 + +require ( + github.com/graphql-go/graphql v0.8.0 + github.com/graphql-go/handler v0.2.3 +) + +replace github.com/graphql-go/graphql => github.com/dariuszkuc/graphql v0.9.0-federation diff --git a/implementations/graphql-go/go.sum b/implementations/graphql-go/go.sum new file mode 100644 index 000000000..c9d8b5204 --- /dev/null +++ b/implementations/graphql-go/go.sum @@ -0,0 +1,4 @@ +github.com/dariuszkuc/graphql v0.9.0-federation h1:dHxP3Z+wX/hRkj/3jaxGXb7iG+Zw3fjW1frQbgsq3lI= +github.com/dariuszkuc/graphql v0.9.0-federation/go.mod h1:nKiHzRM0qopJEwCITUuIsxk9PlVlwIiiI8pnJEhordQ= +github.com/graphql-go/handler v0.2.3 h1:CANh8WPnl5M9uA25c2GBhPqJhE53Fg0Iue/fRNla71E= +github.com/graphql-go/handler v0.2.3/go.mod h1:leLF6RpV5uZMN1CdImAxuiayrYYhOk33bZciaUGaXeU= diff --git a/implementations/graphql-go/metadata.yaml b/implementations/graphql-go/metadata.yaml new file mode 100644 index 000000000..148a27791 --- /dev/null +++ b/implementations/graphql-go/metadata.yaml @@ -0,0 +1,3 @@ +fullName: GraphQL Go +language: Go +documentation: https://github.com/graphql-go/graphql diff --git a/implementations/graphql-go/model/models.go b/implementations/graphql-go/model/models.go new file mode 100644 index 000000000..92b031581 --- /dev/null +++ b/implementations/graphql-go/model/models.go @@ -0,0 +1,47 @@ +package model + +type User struct { + // AverageProductsCreatedPerYear *int `json:"averageProductsCreatedPerYear"` + Email string `json:"email"` + Name *string `json:"name"` + TotalProductsCreated *int `json:"totalProductsCreated"` + YearsOfEmployment int `json:"yearsOfEmployment"` +} + +type CaseStudy struct { + CaseNumber string `json:"caseNumber"` + Description *string `json:"description"` +} + +type DeprecatedProduct struct { + Sku string `json:"sku"` + Package string `json:"package"` + Reason *string `json:"reason"` + CreatedBy *User `json:"createdBy"` +} + +type Product struct { + ID string `json:"id"` + Sku *string `json:"sku"` + Package *string `json:"package"` + Variation *ProductVariation `json:"variation"` + Dimensions *ProductDimension `json:"dimensions"` + CreatedBy *User `json:"createdBy"` + Notes *string `json:"notes"` + Research []*ProductResearch `json:"research"` +} + +type ProductDimension struct { + Size *string `json:"size"` + Weight *float64 `json:"weight"` + Unit *string `json:"unit"` +} + +type ProductResearch struct { + Study *CaseStudy `json:"study"` + Outcome *string `json:"outcome"` +} + +type ProductVariation struct { + ID string `json:"id"` +} diff --git a/implementations/graphql-go/products.graphql b/implementations/graphql-go/products.graphql new file mode 100644 index 000000000..9f727ae17 --- /dev/null +++ b/implementations/graphql-go/products.graphql @@ -0,0 +1,56 @@ +extend schema + @link( + url: "https://specs.apollo.dev/federation/v2.0", + import: ["@extends", "@external", "@inaccessible", "@key", "@override", "@provides", "@requires", "@shareable", "@tag"] + ) + +type Product @key(fields: "id") @key(fields: "sku package") @key(fields: "sku variation { id }") { + id: ID! + sku: String + package: String + variation: ProductVariation + dimensions: ProductDimension + createdBy: User @provides(fields: "totalProductsCreated") + notes: String @tag(name: "internal") + research: [ProductResearch!]! +} + +type DeprecatedProduct @key(fields: "sku package") { + sku: String! + package: String! + reason: String + createdBy: User +} + +type ProductVariation { + id: ID! +} + +type ProductResearch @key(fields: "study { caseNumber }") { + study: CaseStudy! + outcome: String +} + +type CaseStudy { + caseNumber: ID! + description: String +} + +type ProductDimension @shareable { + size: String + weight: Float + unit: String @inaccessible +} + +extend type Query { + product(id: ID!): Product + deprecatedProduct(sku: String!, package: String!): DeprecatedProduct @deprecated(reason: "Use product query instead") +} + +extend type User @key(fields: "email") { + averageProductsCreatedPerYear: Int @requires(fields: "totalProductsCreated yearsOfEmployment") + email: ID! @external + name: String @override(from: "users") + totalProductsCreated: Int @external + yearsOfEmployment: Int! @external +} diff --git a/implementations/graphql-go/resolver/data.go b/implementations/graphql-go/resolver/data.go new file mode 100644 index 000000000..14279e8e3 --- /dev/null +++ b/implementations/graphql-go/resolver/data.go @@ -0,0 +1,78 @@ +package resolver + +import "graphql-go-compatibility/model" + +var federationSku = "federation" +var federationPackage = "@apollo/federation" + +var studioSku = "studio" + +var productSize = "small" +var productWeight = 1.0 +var productUnit = "kg" + +var products = []*model.Product{ + { + ID: "apollo-federation", + Sku: &federationSku, + Package: &federationPackage, + Variation: &model.ProductVariation{ID: "OSS"}, + Dimensions: &model.ProductDimension{ + Size: &productSize, + Weight: &productWeight, + Unit: &productUnit, + }, + }, + { + ID: "apollo-studio", + Sku: &studioSku, + Package: nil, + Variation: &model.ProductVariation{ID: "platform"}, + Dimensions: &model.ProductDimension{ + Size: &productSize, + Weight: &productWeight, + Unit: &productUnit, + }, + }, +} + +var userName = "Jane Smith" +var totalProductsCreated = 1337 + +var users = []*model.User{ + { + Email: "support@apollographql.com", + Name: &userName, + TotalProductsCreated: &totalProductsCreated, + }, +} + +var DefaultUser = users[0] + +var deprecationReason = "Migrate to Federation V2" + +var deprecatedProducts = []*model.DeprecatedProduct{ + { + Sku: "apollo-federation-v1", + Package: "@apollo/federation-v1", + Reason: &deprecationReason, + }, +} + +var federationStudyDescription = "Federation Study" +var studioStudyDescription = "Studio Study" + +var research = []*model.ProductResearch{ + { + Study: &model.CaseStudy{ + CaseNumber: "1234", + Description: &federationStudyDescription, + }, + }, + { + Study: &model.CaseStudy{ + CaseNumber: "1235", + Description: &studioStudyDescription, + }, + }, +} diff --git a/implementations/graphql-go/resolver/resolvers.go b/implementations/graphql-go/resolver/resolvers.go new file mode 100644 index 000000000..487892abe --- /dev/null +++ b/implementations/graphql-go/resolver/resolvers.go @@ -0,0 +1,146 @@ +package resolver + +import ( + "math" + + "graphql-go-compatibility/model" +) + +func FindDeprecatedProductBySkuAndPackage(sku string, packageArg string) (*model.DeprecatedProduct, error) { + for i := range deprecatedProducts { + if deprecatedProducts[i].Sku == sku && deprecatedProducts[i].Package == packageArg { + return deprecatedProducts[i], nil + } + } + return nil, nil +} + +func FindProductById(id string) (*model.Product, error) { + for i := range products { + if products[i].ID == id { + return products[i], nil + } + } + return nil, nil +} + +func FindProductBySkuAndPackage(sku string, packageArg string) (*model.Product, error) { + for i := range products { + if *products[i].Sku == sku && *products[i].Package == packageArg { + return products[i], nil + } + } + return nil, nil +} + +func FindProductBySkuAndVariationID(sku string, variationID string) (*model.Product, error) { + for i := range products { + if *products[i].Sku == sku && products[i].Variation.ID == variationID { + return products[i], nil + } + } + return nil, nil +} + +func FindProductResearchByProductId(product *model.Product) ([]*model.ProductResearch, error) { + switch product.ID { + case "apollo-federation": + return research[:1], nil + case "apollo-studio": + return research[1:], nil + default: + return nil, nil + } +} + +func CalculateAverageProductsCreatedPerYear(user *model.User) (*int, error) { + if user.TotalProductsCreated == nil { + return nil, nil + } + var avgProductsCreated = int(math.Round(float64(*user.TotalProductsCreated) / float64(user.YearsOfEmployment))) + return &avgProductsCreated, nil +} + +// entity resolvers + +// @key(fields: "sku package") +func DeprecatedProductEntityResolver(params map[string]interface{}) (*model.DeprecatedProduct, error) { + sku, skuOk := params["sku"].(string) + pkg, pkgOk := params["package"].(string) + if skuOk && pkgOk { + return FindDeprecatedProductBySkuAndPackage(sku, pkg) + } + return nil, nil +} + +// @key(fields: "id") +// @key(fields: "sku package") +// @key(fields: "sku variation { id }") +func ProductEntityResolver(params map[string]interface{}) (*model.Product, error) { + id, ok := params["id"].(string) + if ok { + return FindProductById(id) + } else { + sku, skuOk := params["sku"].(string) + if skuOk { + pkg, pkgOk := params["package"].(string) + if pkgOk { + return FindProductBySkuAndPackage(sku, pkg) + } else { + variation, varOk := params["variation"].(map[string]interface{}) + if varOk { + varId, varIdOk := variation["id"].(string) + if varIdOk { + return FindProductBySkuAndVariationID(sku, varId) + } + } + } + } + } + return nil, nil +} + +// @key(fields: "study { caseNumber }") +func ProductResearchEntityResolver(params map[string]interface{}) (*model.ProductResearch, error) { + study, ok := params["study"].(map[string]interface{}) + if ok { + caseNumber, caseOk := study["caseNumber"].(string) + if caseOk { + for i := range research { + if research[i].Study.CaseNumber == caseNumber { + return research[i], nil + } + } + } + } + return nil, nil +} + +// @key(fields: "email") +func UserEntityResolver(params map[string]interface{}) (*model.User, error) { + email, ok := params["email"].(string) + if ok { + for i := range users { + var user *model.User + if users[i].Email == email { + user = users[i] + } + + totalProducts, totalSpecified := params["totalProductsCreated"].(float64) + if totalSpecified { + total := int(totalProducts) + user.TotalProductsCreated = &total + } + + yearsOfEmployment, yearsSpecified := params["yearsOfEmployment"].(float64) + if yearsSpecified { + user.YearsOfEmployment = int(yearsOfEmployment) + } + + if user != nil { + return user, nil + } + } + } + return nil, nil +} diff --git a/implementations/graphql-go/server.go b/implementations/graphql-go/server.go new file mode 100644 index 000000000..4bcb4a547 --- /dev/null +++ b/implementations/graphql-go/server.go @@ -0,0 +1,300 @@ +package main + +import ( + "log" + "net/http" + + "graphql-go-compatibility/model" + "graphql-go-compatibility/resolver" + + "github.com/graphql-go/graphql" + "github.com/graphql-go/graphql/federation" + "github.com/graphql-go/handler" +) + +var userType = graphql.NewObject(graphql.ObjectConfig{ + Name: "User", + Fields: graphql.Fields{ + "averageProductsCreatedPerYear": &graphql.Field{ + Type: graphql.Int, + Resolve: func(p graphql.ResolveParams) (interface{}, error) { + user, ok := p.Source.(*model.User) + if ok { + return resolver.CalculateAverageProductsCreatedPerYear(user) + } + return nil, nil + }, + AppliedDirectives: []*graphql.AppliedDirective{ + federation.RequiresAppliedDirective("totalProductsCreated yearsOfEmployment"), + }, + }, + "email": &graphql.Field{ + Type: graphql.NewNonNull(graphql.ID), + AppliedDirectives: []*graphql.AppliedDirective{ + federation.ExternalAppliedDirective, + }, + }, + "name": &graphql.Field{ + Type: graphql.String, + AppliedDirectives: []*graphql.AppliedDirective{ + federation.OverrideAppliedDirective("users"), + }, + }, + "totalProductsCreated": &graphql.Field{ + Type: graphql.Int, + AppliedDirectives: []*graphql.AppliedDirective{ + federation.ExternalAppliedDirective, + }, + }, + "yearsOfEmployment": &graphql.Field{ + Type: graphql.NewNonNull(graphql.Int), + AppliedDirectives: []*graphql.AppliedDirective{ + federation.ExternalAppliedDirective, + }, + }, + }, + AppliedDirectives: []*graphql.AppliedDirective{ + federation.KeyAppliedDirective("email", true), + }, +}) + +var productVariationType = graphql.NewObject(graphql.ObjectConfig{ + Name: "ProductVariation", + Fields: graphql.Fields{ + "id": &graphql.Field{ + Type: graphql.NewNonNull(graphql.ID), + }, + }, +}) + +var productDimensionType = graphql.NewObject(graphql.ObjectConfig{ + Name: "ProductDimension", + Fields: graphql.Fields{ + "size": &graphql.Field{ + Type: graphql.String, + }, + "weight": &graphql.Field{ + Type: graphql.Float, + }, + "unit": &graphql.Field{ + Type: graphql.String, + AppliedDirectives: []*graphql.AppliedDirective{ + federation.InaccessibleAppliedDirective, + }, + }, + }, + AppliedDirectives: []*graphql.AppliedDirective{ + federation.ShareableAppliedDirective, + }, +}) + +var caseStudyType = graphql.NewObject(graphql.ObjectConfig{ + Name: "CaseStudy", + Fields: graphql.Fields{ + "caseNumber": &graphql.Field{ + Type: graphql.NewNonNull(graphql.ID), + }, + "description": &graphql.Field{ + Type: graphql.String, + }, + }, +}) + +var productResearchType = graphql.NewObject(graphql.ObjectConfig{ + Name: "ProductResearch", + Fields: graphql.Fields{ + "study": &graphql.Field{ + Type: graphql.NewNonNull(caseStudyType), + }, + "outcome": &graphql.Field{ + Type: graphql.String, + }, + }, + AppliedDirectives: []*graphql.AppliedDirective{ + federation.KeyAppliedDirective("study { caseNumber }", true), + }, +}) + +var productType = graphql.NewObject(graphql.ObjectConfig{ + Name: "Product", + Fields: graphql.Fields{ + "id": &graphql.Field{ + Type: graphql.NewNonNull(graphql.ID), + }, + "sku": &graphql.Field{ + Type: graphql.String, + }, + "package": &graphql.Field{ + Type: graphql.String, + }, + "variation": &graphql.Field{ + Type: productVariationType, + }, + "dimensions": &graphql.Field{ + Type: productDimensionType, + }, + "createdBy": &graphql.Field{ + Type: userType, + Resolve: func(p graphql.ResolveParams) (interface{}, error) { + return resolver.DefaultUser, nil + }, + AppliedDirectives: []*graphql.AppliedDirective{ + federation.ProvidesAppliedDirective("totalProductsCreated"), + }, + }, + "notes": &graphql.Field{ + Type: graphql.String, + AppliedDirectives: []*graphql.AppliedDirective{ + federation.TagAppliedDirective("internal"), + }, + }, + "research": &graphql.Field{ + Type: graphql.NewNonNull(graphql.NewList(graphql.NewNonNull(productResearchType))), + Resolve: func(p graphql.ResolveParams) (interface{}, error) { + product, ok := p.Source.(*model.Product) + if ok { + return resolver.FindProductResearchByProductId(product) + } + return nil, nil + }, + }, + }, + AppliedDirectives: []*graphql.AppliedDirective{ + federation.KeyAppliedDirective("id", true), + federation.KeyAppliedDirective("sku package", true), + federation.KeyAppliedDirective("sku variation { id }", true), + }, +}) + +var deprecatedProductType = graphql.NewObject(graphql.ObjectConfig{ + Name: "DeprecatedProduct", + Fields: graphql.Fields{ + "sku": &graphql.Field{ + Type: graphql.NewNonNull(graphql.String), + }, + "package": &graphql.Field{ + Type: graphql.NewNonNull(graphql.String), + }, + "reason": &graphql.Field{ + Type: graphql.String, + }, + "createdBy": &graphql.Field{ + Type: userType, + Resolve: func(p graphql.ResolveParams) (interface{}, error) { + return resolver.DefaultUser, nil + }, + }, + }, + AppliedDirectives: []*graphql.AppliedDirective{ + federation.KeyAppliedDirective("sku package", true), + }, +}) + +var rootQuery = graphql.NewObject(graphql.ObjectConfig{ + Name: "Query", + Fields: graphql.Fields{ + "product": &graphql.Field{ + Type: productType, + Args: graphql.FieldConfigArgument{ + "id": &graphql.ArgumentConfig{ + Type: graphql.NewNonNull(graphql.ID), + }, + }, + Resolve: func(params graphql.ResolveParams) (interface{}, error) { + id, ok := params.Args["id"].(string) + if ok { + return resolver.FindProductById(id) + } + return nil, nil + }, + }, + "deprecatedProduct": &graphql.Field{ + Type: deprecatedProductType, + Args: graphql.FieldConfigArgument{ + "sku": &graphql.ArgumentConfig{ + Type: graphql.NewNonNull(graphql.String), + }, + "package": &graphql.ArgumentConfig{ + Type: graphql.NewNonNull(graphql.String), + }, + }, + Resolve: func(params graphql.ResolveParams) (interface{}, error) { + sku, skuOk := params.Args["sku"].(string) + pkg, pkgOk := params.Args["package"].(string) + if skuOk && pkgOk { + return resolver.FindDeprecatedProductBySkuAndPackage(sku, pkg) + } + return nil, nil + }, + DeprecationReason: "Use product query instead", + }, + }, +}) + +var schema, _ = federation.NewFederatedSchema(federation.FederatedSchemaConfig{ + EntitiesFieldResolver: func(p graphql.ResolveParams) (interface{}, error) { + representations, ok := p.Args["representations"].([]interface{}) + results := make([]interface{}, 0) + if ok { + for _, representation := range representations { + raw, isAny := representation.(map[string]interface{}) + if isAny { + typeName, typeSpecified := raw["__typename"].(string) + if typeSpecified { + switch typeName { + case "Product": + product, _ := resolver.ProductEntityResolver(raw) + results = append(results, product) + case "User": + user, _ := resolver.UserEntityResolver(raw) + results = append(results, user) + case "DeprecatedProduct": + deprecatedProduct, _ := resolver.DeprecatedProductEntityResolver(raw) + results = append(results, deprecatedProduct) + case "ProductResearch": + research, _ := resolver.ProductResearchEntityResolver(raw) + results = append(results, research) + } + } else { + panic("Invalid entity representation - missing __typename") + } + } + } + } + return results, nil + }, + EntityTypeResolver: func(p graphql.ResolveTypeParams) *graphql.Object { + if _, ok := p.Value.(*model.Product); ok { + return productType + } + if _, ok := p.Value.(*model.User); ok { + return userType + } + if _, ok := p.Value.(*model.DeprecatedProduct); ok { + return deprecatedProductType + } + if _, ok := p.Value.(*model.ProductResearch); ok { + return productResearchType + } + return nil + }, + SchemaConfig: graphql.SchemaConfig{ + Query: rootQuery, + Types: []graphql.Type{ + userType, + }, + }, +}) + +func main() { + handler := handler.New(&handler.Config{ + Schema: &schema, + Pretty: true, + GraphiQL: true, + }) + + http.Handle("/", handler) + + log.Printf("graphql-go server is accepting requests at http://localhost:4001/") + log.Fatal(http.ListenAndServe(":4001", nil)) +}