├── docs ├── .nojekyll ├── favicon.png ├── overview.png ├── see-also.md ├── boundary-plan.png ├── boundary-merge.png ├── schema.md ├── examples.md ├── debugging.md ├── _sidebar.md ├── index.html ├── getting-started.md ├── README.md ├── write-plugin.md ├── configuration.md ├── logo.svg ├── bramble-header.svg ├── plugins.md ├── sharing-types.md └── access-control.md ├── CODEOWNERS ├── examples ├── nodejs-service │ ├── .gitignore │ ├── Dockerfile │ ├── README.md │ ├── package.json │ ├── schema.graphql │ └── index.js ├── slow-service │ ├── .gitignore │ ├── go.mod │ ├── Dockerfile │ ├── main.go │ ├── schema.graphql │ ├── resolver.go │ └── go.sum ├── graph-gophers-service │ ├── .gitignore │ ├── Dockerfile │ ├── go.mod │ ├── main.go │ ├── schema.graphql │ ├── resolver.go │ └── go.sum ├── gqlgen-service │ ├── .gitignore │ ├── tools.go │ ├── Dockerfile │ ├── gqlgen.yml │ ├── Makefile │ ├── README.md │ ├── gizmos.go │ ├── main.go │ ├── go.mod │ ├── schema.graphql │ ├── resolver.go │ └── go.sum ├── gqlgen-multipart-file-upload-service │ ├── .gitignore │ ├── tools.go │ ├── Dockerfile │ ├── gqlgen.yml │ ├── Makefile │ ├── main.go │ ├── go.mod │ ├── schema.graphql │ ├── README.md │ ├── resolver.go │ └── go.sum ├── gateway.json └── bramble-examples.postman_collection.json ├── .dockerignore ├── .gitignore ├── cmd └── bramble │ └── main.go ├── telemetry_test.go ├── main_test.go ├── plugins ├── playground.go ├── cors_test.go ├── request_id.go ├── headers.go ├── headers_test.go ├── admin_ui_test.go ├── limits.go ├── cors.go ├── request_id_test.go ├── admin_ui.go ├── admin_ui.html.template ├── auth_jwt_test.go └── auth_jwt.go ├── context_test.go ├── .github └── workflows │ ├── movio.yml │ ├── docker.yml │ └── go.yml ├── config.json.example ├── schema.go ├── Dockerfile ├── LICENSE ├── context.go ├── .mailmap ├── config_test.go ├── docker-compose.yaml ├── instrumentation.go ├── testsrv ├── gizmo_test_server.go └── gadget_test_server.go ├── introspection.go ├── gateway.go ├── go.mod ├── main.go ├── README.md ├── instrumentation_test.go ├── metrics.go ├── server_test.go ├── plugin.go ├── middleware.go ├── merge_fixtures_test.go ├── client_test.go ├── gateway_test.go ├── telemetry.go └── format.go /docs/.nojekyll: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @movio/backend 2 | -------------------------------------------------------------------------------- /examples/nodejs-service/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /examples/slow-service/.gitignore: -------------------------------------------------------------------------------- 1 | slow-service 2 | -------------------------------------------------------------------------------- /examples/graph-gophers-service/.gitignore: -------------------------------------------------------------------------------- 1 | graph-gophers-service 2 | -------------------------------------------------------------------------------- /docs/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/movio/bramble/HEAD/docs/favicon.png -------------------------------------------------------------------------------- /docs/overview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/movio/bramble/HEAD/docs/overview.png -------------------------------------------------------------------------------- /docs/see-also.md: -------------------------------------------------------------------------------- 1 | # See also 2 | 3 | ## GraphQL Clients 4 | 5 | ## Go Libraries 6 | -------------------------------------------------------------------------------- /examples/gqlgen-service/.gitignore: -------------------------------------------------------------------------------- 1 | gqlgen-service 2 | generated.go 3 | models_gen.go 4 | -------------------------------------------------------------------------------- /docs/boundary-plan.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/movio/bramble/HEAD/docs/boundary-plan.png -------------------------------------------------------------------------------- /docs/boundary-merge.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/movio/bramble/HEAD/docs/boundary-merge.png -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .git 2 | Dockerfile 3 | Makefile 4 | bramble 5 | contrib 6 | docs 7 | examples 8 | testsrv 9 | -------------------------------------------------------------------------------- /examples/gqlgen-multipart-file-upload-service/.gitignore: -------------------------------------------------------------------------------- 1 | gqlgen-service 2 | generated.go 3 | models_gen.go 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /bramble 2 | .vscode 3 | .history 4 | .idea 5 | .DS_Store 6 | coverage.out 7 | *__debug_* 8 | config.json 9 | -------------------------------------------------------------------------------- /docs/schema.md: -------------------------------------------------------------------------------- 1 | # GraphQL schema guide 2 | 3 | # Schema requirements 4 | 5 | # @namespace directive 6 | 7 | # Boundary types 8 | -------------------------------------------------------------------------------- /examples/nodejs-service/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:18-alpine3.19 2 | 3 | COPY . . 4 | 5 | RUN npm install 6 | 7 | CMD ["npm", "run", "start"] 8 | -------------------------------------------------------------------------------- /examples/nodejs-service/README.md: -------------------------------------------------------------------------------- 1 | # Example nodejs based service 2 | 3 | This is an example service that adds a `rating` field to the `Gizmo` boundary type. 4 | -------------------------------------------------------------------------------- /examples/slow-service/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/movio/bramble/examples/slow-service 2 | 3 | go 1.23.3 4 | 5 | require github.com/graph-gophers/graphql-go v1.4.0 6 | -------------------------------------------------------------------------------- /examples/slow-service/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.22-alpine3.19 2 | 3 | ENV CGO_ENABLED=0 4 | 5 | WORKDIR /go/src/app 6 | 7 | COPY . . 8 | RUN go get 9 | CMD ["go", "run", "."] 10 | -------------------------------------------------------------------------------- /cmd/bramble/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/movio/bramble" 5 | _ "github.com/movio/bramble/plugins" 6 | ) 7 | 8 | func main() { 9 | bramble.Main() 10 | } 11 | -------------------------------------------------------------------------------- /examples/gqlgen-service/tools.go: -------------------------------------------------------------------------------- 1 | //go:build tools 2 | 3 | package tools 4 | 5 | import ( 6 | _ "github.com/99designs/gqlgen" 7 | _ "github.com/99designs/gqlgen/graphql/introspection" 8 | ) 9 | -------------------------------------------------------------------------------- /examples/graph-gophers-service/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.22-alpine3.19 2 | 3 | ENV CGO_ENABLED=0 4 | 5 | WORKDIR /go/src/app 6 | 7 | COPY . . 8 | RUN go get 9 | CMD ["go", "run", "."] 10 | -------------------------------------------------------------------------------- /examples/gqlgen-multipart-file-upload-service/tools.go: -------------------------------------------------------------------------------- 1 | //go:build tools 2 | 3 | package tools 4 | 5 | import ( 6 | _ "github.com/99designs/gqlgen" 7 | _ "github.com/99designs/gqlgen/graphql/introspection" 8 | ) 9 | -------------------------------------------------------------------------------- /examples/gqlgen-service/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.22-alpine3.19 2 | 3 | ENV CGO_ENABLED=0 4 | 5 | WORKDIR /go/src/app 6 | 7 | COPY . . 8 | 9 | RUN go generate . 10 | RUN go get 11 | CMD ["go", "run", "."] 12 | -------------------------------------------------------------------------------- /examples/gateway.json: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": [ 3 | { 4 | "name": "limits", 5 | "config": { 6 | "max-request-bytes": 1048576, 7 | "max-response-time": "2s" 8 | } 9 | } 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /examples/gqlgen-service/gqlgen.yml: -------------------------------------------------------------------------------- 1 | schema: 2 | - schema.graphql 3 | exec: 4 | filename: generated.go 5 | model: 6 | filename: models_gen.go 7 | resolver: 8 | filename: resolver.go 9 | type: Resolver 10 | autobind: [] 11 | -------------------------------------------------------------------------------- /examples/gqlgen-multipart-file-upload-service/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.22-alpine3.19 2 | 3 | ENV CGO_ENABLED=0 4 | 5 | WORKDIR /go/src/app 6 | 7 | COPY . . 8 | 9 | RUN go generate . 10 | RUN go get 11 | CMD ["go", "run", "."] 12 | -------------------------------------------------------------------------------- /examples/gqlgen-multipart-file-upload-service/gqlgen.yml: -------------------------------------------------------------------------------- 1 | schema: 2 | - schema.graphql 3 | exec: 4 | filename: generated.go 5 | model: 6 | filename: models_gen.go 7 | resolver: 8 | filename: resolver.go 9 | type: Resolver 10 | autobind: [] 11 | -------------------------------------------------------------------------------- /telemetry_test.go: -------------------------------------------------------------------------------- 1 | package bramble 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/require" 7 | ) 8 | 9 | func TestResources(t *testing.T) { 10 | cfg := TelemetryConfig{Enabled: true} 11 | _, err := resources(cfg) 12 | require.NoError(t, err) 13 | } 14 | -------------------------------------------------------------------------------- /examples/graph-gophers-service/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/movio/bramble/examples/graph-gophers-service 2 | 3 | go 1.23.3 4 | 5 | require ( 6 | github.com/go-faker/faker/v4 v4.0.0-beta.3 7 | github.com/graph-gophers/graphql-go v1.4.0 8 | ) 9 | 10 | require golang.org/x/text v0.3.8 // indirect 11 | -------------------------------------------------------------------------------- /main_test.go: -------------------------------------------------------------------------------- 1 | package bramble 2 | 3 | import ( 4 | "flag" 5 | "io" 6 | log "log/slog" 7 | "os" 8 | "testing" 9 | ) 10 | 11 | func TestMain(m *testing.M) { 12 | flag.Parse() 13 | if !testing.Verbose() { 14 | blackhole := log.New(log.NewTextHandler(io.Discard, nil)) 15 | log.SetDefault(blackhole) 16 | } 17 | os.Exit(m.Run()) 18 | } 19 | -------------------------------------------------------------------------------- /examples/gqlgen-service/Makefile: -------------------------------------------------------------------------------- 1 | ARTIFACT=gqlgen-service 2 | DEF=gqlgen.yml schema.graphql 3 | GEN=models_gen.go generated.go 4 | 5 | build: $(ARTIFACT) 6 | 7 | .PHONY: clean 8 | clean: 9 | rm -f $(ARTIFACT) $(GEN) 10 | 11 | .PHONY: generate 12 | generate: $(GEN) 13 | 14 | $(GEN): $(DEF) 15 | go generate 16 | 17 | gqlgen-service: $(GEN) $(wildcard *.go) 18 | go build 19 | -------------------------------------------------------------------------------- /examples/gqlgen-multipart-file-upload-service/Makefile: -------------------------------------------------------------------------------- 1 | ARTIFACT=gqlgen-service 2 | DEF=gqlgen.yml schema.graphql 3 | GEN=models_gen.go generated.go 4 | 5 | build: $(ARTIFACT) 6 | 7 | .PHONY: clean 8 | clean: 9 | rm -f $(ARTIFACT) $(GEN) 10 | 11 | .PHONY: generate 12 | generate: $(GEN) 13 | 14 | $(GEN): $(DEF) 15 | go generate 16 | 17 | gqlgen-service: $(GEN) $(wildcard *.go) 18 | go build 19 | -------------------------------------------------------------------------------- /examples/gqlgen-service/README.md: -------------------------------------------------------------------------------- 1 | # Example gqlgen based service 2 | 3 | This is an example service that exposes a very simple schema: 4 | 5 | type Foo { 6 | id: ID! 7 | gqlgen: Boolean! 8 | } 9 | 10 | Other example services will add other fields to the `Foo` object. 11 | 12 | _Note: we have not added `gqlgen` related generated files to git; must `go generate .` before use_ 13 | -------------------------------------------------------------------------------- /examples/gqlgen-service/gizmos.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "strconv" 5 | 6 | "github.com/go-faker/faker/v4" 7 | ) 8 | 9 | func generateGizmos() map[string]*Gizmo { 10 | var gizmos = map[string]*Gizmo{} 11 | ids, _ := faker.RandomInt(100, 999, 10) 12 | for _, id := range ids { 13 | idstr := strconv.Itoa(id) 14 | gizmos[idstr] = &Gizmo{ 15 | ID: idstr, 16 | Name: faker.Name(), 17 | } 18 | } 19 | return gizmos 20 | } 21 | -------------------------------------------------------------------------------- /docs/examples.md: -------------------------------------------------------------------------------- 1 | # Example Services 2 | 3 | ## Go 4 | 5 | - graphql-go (https://github.com/graph-gophers/graphql-go) 6 | 7 | [Example code](https://github.com/movio/bramble/tree/main/examples/graph-gophers-service) 8 | 9 | - gqlgen (https://github.com/99designs/gqlgen) 10 | 11 | [Example code](https://github.com/movio/bramble/tree/main/examples/gqlgen-service) 12 | 13 | ## Node.js 14 | 15 | [Example code](https://github.com/movio/bramble/tree/main/examples/nodejs-service) 16 | -------------------------------------------------------------------------------- /examples/slow-service/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | _ "embed" 5 | "fmt" 6 | "net/http" 7 | "os" 8 | 9 | "log" 10 | ) 11 | 12 | func main() { 13 | addr := os.Getenv("ADDR") 14 | if addr == "" { 15 | addr = ":8080" 16 | } 17 | 18 | http.Handle("/query", newResolver()) 19 | http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { 20 | fmt.Fprintln(w, "OK") 21 | }) 22 | log.Printf("example %s running on %s", name, addr) 23 | http.ListenAndServe(addr, nil) 24 | } 25 | -------------------------------------------------------------------------------- /docs/debugging.md: -------------------------------------------------------------------------------- 1 | # Debugging 2 | 3 | ## Debug headers 4 | 5 | If the `X-Bramble-Debug` header is present Bramble will add the requested debug information to the response `extensions`. 6 | One or multiple of the following options can be provided (white space separated): 7 | 8 | - `variables`: input variables 9 | - `query`: input query 10 | - `plan`: the query plan, including services and subqueries 11 | - `timing`: total execution time for the query (as a duration string, e.g. `12ms`) 12 | - `all` (all of the above) 13 | -------------------------------------------------------------------------------- /examples/graph-gophers-service/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | _ "embed" 5 | "fmt" 6 | "net/http" 7 | "os" 8 | 9 | "log" 10 | ) 11 | 12 | func main() { 13 | addr := os.Getenv("ADDR") 14 | if addr == "" { 15 | addr = ":8080" 16 | } 17 | 18 | http.Handle("/query", newResolver()) 19 | http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { 20 | fmt.Fprintln(w, "OK") 21 | }) 22 | log.Printf("example %s running on %s", name, addr) 23 | http.ListenAndServe(addr, nil) 24 | } 25 | -------------------------------------------------------------------------------- /examples/nodejs-service/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nodejs-service", 3 | "version": "1.0.0", 4 | "description": "Example nodejs based service", 5 | "main": "index.js", 6 | "scripts": { 7 | "start": "node index.js" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "https://github.com/movio/bramble" 12 | }, 13 | "author": "", 14 | "license": "MIT", 15 | "dependencies": { 16 | "express": "^4.21.2", 17 | "express-graphql": "^0.12.0", 18 | "graphql": "^14.7.0" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /docs/_sidebar.md: -------------------------------------------------------------------------------- 1 | - [Overview](/) 2 | 3 | - **Getting started** 4 | - [Quick Start](/getting-started.md) 5 | - [Sharing types across services](/sharing-types.md) 6 | 7 | - **Guide** 8 | - [Access Control](/access-control.md) 9 | - [Debugging](/debugging.md) 10 | - [Example Services](/examples.md) 11 | 12 | - **Customisation** 13 | - [Configuration](/configuration.md) 14 | - [Plugins](/plugins.md) 15 | - [Writing a plugin](/write-plugin.md) 16 | 17 | - **Specifications** 18 | 19 | - [Federation](/federation.md) 20 | - [Algorithms](/algorithms.md) 21 | -------------------------------------------------------------------------------- /examples/gqlgen-service/main.go: -------------------------------------------------------------------------------- 1 | //go:generate go run github.com/99designs/gqlgen 2 | package main 3 | 4 | import ( 5 | _ "embed" 6 | "fmt" 7 | "log" 8 | "net/http" 9 | "os" 10 | ) 11 | 12 | func main() { 13 | addr := os.Getenv("ADDR") 14 | if addr == "" { 15 | addr = ":8080" 16 | } 17 | 18 | http.Handle("/query", newResolver()) 19 | http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { 20 | fmt.Fprintln(w, "OK") 21 | }) 22 | log.Printf("example %s running on %s", name, addr) 23 | log.Fatal(http.ListenAndServe(addr, nil)) 24 | } 25 | -------------------------------------------------------------------------------- /plugins/playground.go: -------------------------------------------------------------------------------- 1 | package plugins 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/99designs/gqlgen/graphql/playground" 7 | "github.com/movio/bramble" 8 | ) 9 | 10 | func init() { 11 | bramble.RegisterPlugin(&PlaygroundPlugin{}) 12 | } 13 | 14 | type PlaygroundPlugin struct { 15 | *bramble.BasePlugin 16 | } 17 | 18 | func (p *PlaygroundPlugin) ID() string { 19 | return "playground" 20 | } 21 | 22 | func (p *PlaygroundPlugin) SetupPublicMux(mux *http.ServeMux) { 23 | mux.HandleFunc("/playground", playground.Handler("Bramble Playground", "/query")) 24 | } 25 | -------------------------------------------------------------------------------- /examples/gqlgen-multipart-file-upload-service/main.go: -------------------------------------------------------------------------------- 1 | //go:generate go run github.com/99designs/gqlgen 2 | package main 3 | 4 | import ( 5 | _ "embed" 6 | "fmt" 7 | "log" 8 | "net/http" 9 | "os" 10 | ) 11 | 12 | func main() { 13 | addr := os.Getenv("ADDR") 14 | if addr == "" { 15 | addr = ":8080" 16 | } 17 | 18 | http.Handle("/query", newResolver()) 19 | http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { 20 | fmt.Fprintln(w, "OK") 21 | }) 22 | log.Printf("example %s running on %s", name, addr) 23 | log.Fatal(http.ListenAndServe(addr, nil)) 24 | } 25 | -------------------------------------------------------------------------------- /context_test.go: -------------------------------------------------------------------------------- 1 | package bramble 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestContextOutgoingRequestHeaders(t *testing.T) { 12 | ctx := context.Background() 13 | ctx = AddOutgoingRequestsHeaderToContext(ctx, "My-Header-1", "value1") 14 | ctx = AddOutgoingRequestsHeaderToContext(ctx, "My-Header-1", "value2") 15 | ctx = AddOutgoingRequestsHeaderToContext(ctx, "My-Header-2", "value3") 16 | 17 | header := GetOutgoingRequestHeadersFromContext(ctx) 18 | assert.Equal(t, http.Header{"My-Header-1": []string{"value1", "value2"}, 19 | "My-Header-2": []string{"value3"}, 20 | }, header) 21 | } 22 | -------------------------------------------------------------------------------- /.github/workflows/movio.yml: -------------------------------------------------------------------------------- 1 | name: Notify Movio 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | release: 8 | types: 9 | - published 10 | 11 | concurrency: 12 | group: movio 13 | cancel-in-progress: true 14 | 15 | jobs: 16 | dispatch: 17 | runs-on: ubuntu-latest 18 | steps: 19 | - name: Notify Movio 20 | uses: peter-evans/repository-dispatch@v3 21 | with: 22 | token: ${{ secrets.MOVIO_ACTIONS_ACCESS }} 23 | repository: movio/bramble-movio 24 | event-type: ${{ github.event_name }} 25 | client-payload: |- 26 | { 27 | "repository": "${{ github.repository }}", 28 | "ref": "${{ github.ref }}", 29 | "ref_name": "${{ github.ref_name }}", 30 | "sha": "${{ github.sha }}" 31 | } 32 | -------------------------------------------------------------------------------- /config.json.example: -------------------------------------------------------------------------------- 1 | { 2 | "services": ["http://localhost:4000/graphql"], 3 | "default-timeouts": { 4 | "read": "5s", 5 | "idle": "120s" 6 | }, 7 | "gateway-port": 8082, 8 | "gateway-timeouts": { 9 | "write": "20s" 10 | }, 11 | "private-port": 8083, 12 | "private-timeouts": { 13 | "write": "10s" 14 | }, 15 | "metrics-port": 8084, 16 | "log-level": "info", 17 | "poll-interval": "5s", 18 | "max-requests-per-query": 50, 19 | "max-service-response-size": 1048576, 20 | "disable-introspection": false, 21 | "plugins": [ 22 | { 23 | "name": "admin-ui" 24 | }, 25 | { 26 | "name": "cors", 27 | "config": { 28 | "allowed-origins": ["*"], 29 | "allowed-headers": ["*"], 30 | "allow-credentials": true, 31 | "max-age": 3600, 32 | "debug": true 33 | } 34 | }, 35 | { 36 | "name": "playground" 37 | } 38 | ] 39 | } 40 | -------------------------------------------------------------------------------- /.github/workflows/docker.yml: -------------------------------------------------------------------------------- 1 | name: Publish Docker image 2 | on: 3 | push: 4 | tags: 5 | - 'v*.*.*' 6 | jobs: 7 | push-to-ghcr: 8 | name: Push image to Github Container Registry 9 | runs-on: ubuntu-latest 10 | permissions: 11 | contents: read 12 | packages: write 13 | 14 | steps: 15 | - uses: actions/checkout@v4 16 | 17 | - uses: docker/login-action@v3 18 | with: 19 | registry: ghcr.io 20 | username: ${{ github.actor }} 21 | password: ${{ secrets.GITHUB_TOKEN }} 22 | 23 | - uses: docker/metadata-action@v5 24 | id: meta 25 | with: 26 | images: ghcr.io/${{ github.repository }} 27 | 28 | - uses: docker/build-push-action@v5 29 | with: 30 | context: . 31 | push: true 32 | build-args: | 33 | VERSION=${{ github.ref_name }} 34 | tags: ${{ steps.meta.outputs.tags }} 35 | labels: ${{ steps.meta.outputs.labels }} 36 | -------------------------------------------------------------------------------- /examples/gqlgen-multipart-file-upload-service/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/movio/bramble/examples/gqlgen-service 2 | 3 | go 1.23.3 4 | 5 | require github.com/99designs/gqlgen v0.17.44 6 | 7 | require ( 8 | github.com/agnivade/levenshtein v1.1.1 // indirect 9 | github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect 10 | github.com/google/uuid v1.6.0 // indirect 11 | github.com/gorilla/websocket v1.5.0 // indirect 12 | github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect 13 | github.com/mitchellh/mapstructure v1.5.0 // indirect 14 | github.com/russross/blackfriday/v2 v2.1.0 // indirect 15 | github.com/sosodev/duration v1.2.0 // indirect 16 | github.com/urfave/cli/v2 v2.27.1 // indirect 17 | github.com/vektah/gqlparser/v2 v2.5.15 // indirect 18 | github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect 19 | golang.org/x/mod v0.14.0 // indirect 20 | golang.org/x/text v0.14.0 // indirect 21 | golang.org/x/tools v0.17.0 // indirect 22 | gopkg.in/yaml.v3 v3.0.1 // indirect 23 | ) 24 | -------------------------------------------------------------------------------- /schema.go: -------------------------------------------------------------------------------- 1 | package bramble 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/vektah/gqlparser/v2/ast" 7 | ) 8 | 9 | var IdFieldName = "id" 10 | 11 | const ( 12 | nodeRootFieldName = "node" 13 | nodeInterfaceName = "Node" 14 | serviceObjectName = "Service" 15 | serviceRootFieldName = "service" 16 | boundaryDirectiveName = "boundary" 17 | namespaceDirectiveName = "namespace" 18 | 19 | queryObjectName = "Query" 20 | mutationObjectName = "Mutation" 21 | subscriptionObjectName = "Subscription" 22 | 23 | internalServiceName = "__bramble" 24 | ) 25 | 26 | func isGraphQLBuiltinName(s string) bool { 27 | return strings.HasPrefix(s, "__") 28 | } 29 | 30 | func isIDType(t *ast.Type) bool { 31 | return isNonNullableTypeNamed(t, "ID") 32 | } 33 | 34 | func isNonNullableTypeNamed(t *ast.Type, typename string) bool { 35 | return t.Name() == typename && t.NonNull 36 | } 37 | 38 | func isNullableTypeNamed(t *ast.Type, typename string) bool { 39 | return t.Name() == typename && !t.NonNull 40 | } 41 | -------------------------------------------------------------------------------- /examples/gqlgen-service/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/movio/bramble/examples/gqlgen-service 2 | 3 | go 1.23.3 4 | 5 | require ( 6 | github.com/99designs/gqlgen v0.17.44 7 | github.com/go-faker/faker/v4 v4.0.0-beta.3 8 | ) 9 | 10 | require ( 11 | github.com/agnivade/levenshtein v1.1.1 // indirect 12 | github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect 13 | github.com/google/uuid v1.6.0 // indirect 14 | github.com/gorilla/websocket v1.5.0 // indirect 15 | github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect 16 | github.com/mitchellh/mapstructure v1.5.0 // indirect 17 | github.com/russross/blackfriday/v2 v2.1.0 // indirect 18 | github.com/sosodev/duration v1.2.0 // indirect 19 | github.com/urfave/cli/v2 v2.27.1 // indirect 20 | github.com/vektah/gqlparser/v2 v2.5.15 // indirect 21 | github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect 22 | golang.org/x/mod v0.14.0 // indirect 23 | golang.org/x/text v0.14.0 // indirect 24 | golang.org/x/tools v0.17.0 // indirect 25 | gopkg.in/yaml.v3 v3.0.1 // indirect 26 | ) 27 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | ARG ALPINE_VERSION=3.20 2 | ARG GO_VERSION=1.23 3 | 4 | FROM golang:${GO_VERSION}-alpine${ALPINE_VERSION} AS builder 5 | 6 | ARG VERSION=SNAPSHOT 7 | ENV CGO_ENABLED=0 GOOS=linux 8 | 9 | WORKDIR /workspace 10 | 11 | COPY go.mod go.sum /workspace/ 12 | 13 | RUN --mount=type=cache,target=/go/pkg/mod go mod download 14 | 15 | COPY . /workspace/ 16 | 17 | RUN --mount=type=cache,target=/go/pkg/mod --mount=type=cache,target=/root/.cache/go-build go build -ldflags="-X 'github.com/movio/bramble.Version=$VERSION'" -o bramble ./cmd/bramble 18 | 19 | FROM gcr.io/distroless/static 20 | 21 | ARG VERSION=SNAPSHOT 22 | 23 | LABEL org.opencontainers.image.title="Bramble" 24 | LABEL org.opencontainers.image.description="A federated GraphQL API gateway" 25 | LABEL org.opencontainers.image.version="${VERSION}" 26 | LABEL org.opencontainers.image.source="https://github.com/movio/bramble" 27 | LABEL org.opencontainers.image.documentation="https://movio.github.io/bramble/" 28 | 29 | COPY --from=builder /workspace/bramble . 30 | 31 | EXPOSE 8082 32 | EXPOSE 8083 33 | EXPOSE 8084 34 | 35 | ENTRYPOINT [ "/bramble" ] 36 | -------------------------------------------------------------------------------- /examples/gqlgen-multipart-file-upload-service/schema.graphql: -------------------------------------------------------------------------------- 1 | """ 2 | This is the prerequisite schema required for federation by the gateway 3 | """ 4 | directive @boundary on OBJECT | FIELD_DEFINITION 5 | 6 | scalar Upload 7 | 8 | """ 9 | The `Service` type provides the gateway with a schema to merge into the graph 10 | and a name/version to reference the service by 11 | """ 12 | type Service { 13 | """ 14 | name of the service 15 | """ 16 | name: String! 17 | """ 18 | the service version tag 19 | """ 20 | version: String! 21 | """ 22 | a string of the complete schema 23 | """ 24 | schema: String! 25 | } 26 | 27 | type Query { 28 | """ 29 | The service query is used by the gateway when the service is first registered 30 | """ 31 | service: Service! 32 | } 33 | 34 | input GadgetInput { 35 | upload: Upload! 36 | } 37 | 38 | type Mutation { 39 | """ 40 | Mutation to upload file using multipart request spec: 41 | https://github.com/jaydenseric/graphql-multipart-request-spec. 42 | """ 43 | uploadGizmoFile(upload: Upload!): String! 44 | uploadGadgetFile(upload: GadgetInput!): String! 45 | } 46 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Movio 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /examples/nodejs-service/schema.graphql: -------------------------------------------------------------------------------- 1 | """ 2 | This is the prerequisite schema required for federation by the gateway 3 | """ 4 | directive @boundary on OBJECT | FIELD_DEFINITION 5 | 6 | """ 7 | The `Service` type provides the gateway with a schema to merge into the graph 8 | and a name/version to reference the service by 9 | """ 10 | type Service { 11 | """ 12 | name of the service 13 | """ 14 | name: String! 15 | """ 16 | the service version tag 17 | """ 18 | version: String! 19 | """ 20 | a string of the complete schema 21 | """ 22 | schema: String! 23 | } 24 | 25 | type Query { 26 | """ 27 | The service query is used by the gateway when the service is first registered 28 | """ 29 | service: Service! 30 | 31 | """ 32 | getter for the `Gizmo` type 33 | it will not be exposed in the federated schema 34 | """ 35 | gizmo(id: ID!): Gizmo @boundary 36 | } 37 | 38 | type Gizmo @boundary { 39 | """ 40 | identifier field 41 | required for `@boundary` types 42 | """ 43 | id: ID! 44 | 45 | """ 46 | rating of the `Gizmo` 47 | provided by `nodejs-service` 48 | """ 49 | rating: Int! 50 | } 51 | -------------------------------------------------------------------------------- /examples/graph-gophers-service/schema.graphql: -------------------------------------------------------------------------------- 1 | """ 2 | This is the prerequisite schema required for federation by the gateway 3 | """ 4 | directive @boundary on OBJECT | FIELD_DEFINITION 5 | 6 | """ 7 | The `Service` type provides the gateway with a schema to merge into the graph 8 | and a name/version to reference the service by 9 | """ 10 | type Service { 11 | """ 12 | name of the service 13 | """ 14 | name: String! 15 | """ 16 | the service version tag 17 | """ 18 | version: String! 19 | """ 20 | a string of the complete schema 21 | """ 22 | schema: String! 23 | } 24 | 25 | type Query { 26 | """ 27 | The service query is used by the gateway when the service is first registered 28 | """ 29 | service: Service! 30 | 31 | """ 32 | array getter for the `Gizmo` type 33 | it will not be exposed in the federated schema 34 | """ 35 | gizmos(ids: [ID!]!): [Gizmo]! @boundary 36 | } 37 | 38 | type Gizmo @boundary { 39 | """ 40 | identifier field 41 | required for `@boundary` types 42 | """ 43 | id: ID! 44 | 45 | """ 46 | email of the `Gizmo` 47 | provided by `graph-gophers-service` 48 | """ 49 | email: String! 50 | } 51 | -------------------------------------------------------------------------------- /examples/slow-service/schema.graphql: -------------------------------------------------------------------------------- 1 | """ 2 | This is the prerequisite schema required for federation by the gateway 3 | """ 4 | directive @boundary on OBJECT | FIELD_DEFINITION 5 | 6 | """ 7 | The `Service` type provides the gateway with a schema to merge into the graph 8 | and a name/version to reference the service by 9 | """ 10 | type Service { 11 | """ 12 | name of the service 13 | """ 14 | name: String! 15 | """ 16 | the service version tag 17 | """ 18 | version: String! 19 | """ 20 | a string of the complete schema 21 | """ 22 | schema: String! 23 | } 24 | 25 | type Query { 26 | """ 27 | The service query is used by the gateway when the service is first registered 28 | """ 29 | service: Service! 30 | 31 | """ 32 | array getter for the `Gizmo` type 33 | it will not be exposed in the federated schema 34 | """ 35 | gizmos(ids: [ID!]!): [Gizmo]! @boundary 36 | } 37 | 38 | type Gizmo @boundary { 39 | """ 40 | identifier field 41 | required for `@boundary` types 42 | """ 43 | id: ID! 44 | 45 | """ 46 | delay the response of the `Gizmo` by `duration` 47 | provided by `slow-service` 48 | """ 49 | delay(duration: String! = "1ms"): String 50 | } 51 | -------------------------------------------------------------------------------- /.github/workflows/go.yml: -------------------------------------------------------------------------------- 1 | name: Go 2 | on: [push, pull_request] 3 | jobs: 4 | build: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - uses: actions/checkout@v4 8 | 9 | - name: Set up Go 10 | uses: actions/setup-go@v5 11 | with: 12 | go-version-file: go.mod 13 | 14 | - name: Build 15 | run: go build ./cmd/bramble 16 | 17 | - name: Test 18 | run: go test -race -coverprofile=coverage.txt -covermode=atomic ./... 19 | 20 | - name: Image 21 | uses: docker/build-push-action@v4 22 | with: 23 | context: . 24 | push: false 25 | build-args: | 26 | VERSION=${{ github.ref_name }} 27 | 28 | - uses: codecov/codecov-action@v3 29 | name: Upload to codecov.io 30 | with: 31 | files: ./coverage.txt 32 | 33 | lint: 34 | runs-on: ubuntu-latest 35 | steps: 36 | - uses: actions/checkout@v4 37 | - uses: actions/setup-go@v5 38 | with: 39 | go-version-file: go.mod 40 | - name: Lint 41 | uses: golangci/golangci-lint-action@v6 42 | with: 43 | version: v1.61.0 44 | args: --disable errcheck . plugins 45 | only-new-issues: true 46 | -------------------------------------------------------------------------------- /examples/gqlgen-service/schema.graphql: -------------------------------------------------------------------------------- 1 | """ 2 | This is the prerequisite schema required for federation by the gateway 3 | """ 4 | directive @boundary on OBJECT | FIELD_DEFINITION 5 | 6 | """ 7 | The `Service` type provides the gateway with a schema to merge into the graph 8 | and a name/version to reference the service by 9 | """ 10 | type Service { 11 | """ 12 | name of the service 13 | """ 14 | name: String! 15 | """ 16 | the service version tag 17 | """ 18 | version: String! 19 | """ 20 | a string of the complete schema 21 | """ 22 | schema: String! 23 | } 24 | 25 | type Query { 26 | """ 27 | The service query is used by the gateway when the service is first registered 28 | """ 29 | service: Service! 30 | 31 | """ 32 | getter for the `Gizmo` type 33 | it will not be exposed in the federated schema 34 | """ 35 | gizmo(id: ID!): Gizmo @boundary 36 | 37 | """ 38 | find a random `Gizmo` 39 | this field will be available in the federated schema 40 | """ 41 | randomGizmo: Gizmo! 42 | } 43 | 44 | type Gizmo @boundary { 45 | """ 46 | identifier field 47 | required for `@boundary` types 48 | """ 49 | id: ID! 50 | 51 | """ 52 | name of the `Gizmo` 53 | provided by `gqlgen-service` 54 | """ 55 | name: String! 56 | } 57 | -------------------------------------------------------------------------------- /plugins/cors_test.go: -------------------------------------------------------------------------------- 1 | package plugins 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestCors(t *testing.T) { 12 | p := NewCorsPlugin(CorsPluginConfig{ 13 | AllowedOrigins: []string{"https://example.com"}, 14 | AllowedHeaders: []string{"X-My-Header"}, 15 | AllowCredentials: true, 16 | MaxAge: 3600, 17 | }) 18 | 19 | var handler http.Handler 20 | handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}) 21 | handler = p.ApplyMiddlewarePublicMux(handler) 22 | 23 | req := httptest.NewRequest(http.MethodPost, "/query", nil) 24 | req.Header.Add("Origin", "https://example.com") 25 | rr := httptest.NewRecorder() 26 | handler.ServeHTTP(rr, req) 27 | 28 | assert.Equal(t, "https://example.com", rr.Header().Get("Access-Control-Allow-Origin")) 29 | 30 | req = httptest.NewRequest(http.MethodOptions, "/query", nil) 31 | req.Header.Add("Origin", "https://example.com") 32 | req.Header.Add("Access-Control-Request-Method", "POST") 33 | req.Header.Add("Access-Control-Request-Headers", "X-My-Header") 34 | rr = httptest.NewRecorder() 35 | handler.ServeHTTP(rr, req) 36 | 37 | assert.Equal(t, "X-My-Header", rr.Header().Get("Access-Control-Allow-Headers")) 38 | assert.Equal(t, "3600", rr.Header().Get("Access-Control-Max-Age")) 39 | } 40 | -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Bramble GraphQL Gateway 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 |
16 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /plugins/request_id.go: -------------------------------------------------------------------------------- 1 | package plugins 2 | 3 | import ( 4 | "net/http" 5 | "strings" 6 | 7 | "github.com/gofrs/uuid" 8 | "github.com/movio/bramble" 9 | ) 10 | 11 | const BrambleRequestHeader = "X-Request-Id" 12 | 13 | func init() { 14 | bramble.RegisterPlugin(&RequestIdentifierPlugin{}) 15 | } 16 | 17 | type RequestIdentifierPlugin struct { 18 | bramble.BasePlugin 19 | } 20 | 21 | func (p *RequestIdentifierPlugin) ID() string { 22 | return "request-id" 23 | } 24 | 25 | func (p *RequestIdentifierPlugin) middleware(h http.Handler) http.HandlerFunc { 26 | return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { 27 | requestID := r.Header.Get(BrambleRequestHeader) 28 | 29 | ctx := r.Context() 30 | if strings.TrimSpace(requestID) == "" { 31 | requestID = uuid.Must(uuid.NewV4()).String() 32 | } else if id, err := uuid.FromString(requestID); err == nil { 33 | requestID = id.String() 34 | } 35 | bramble.AddField(ctx, "request.id", requestID) 36 | 37 | ctx = bramble.AddOutgoingRequestsHeaderToContext(ctx, BrambleRequestHeader, requestID) 38 | h.ServeHTTP(rw, r.WithContext(ctx)) 39 | }) 40 | } 41 | 42 | func (p *RequestIdentifierPlugin) ApplyMiddlewarePublicMux(h http.Handler) http.Handler { 43 | return p.middleware(h) 44 | } 45 | 46 | func (p *RequestIdentifierPlugin) ApplyMiddlewarePrivateMux(h http.Handler) http.Handler { 47 | return p.middleware(h) 48 | } 49 | -------------------------------------------------------------------------------- /docs/getting-started.md: -------------------------------------------------------------------------------- 1 | # Getting started 2 | 3 | ## Preparing your services for federation 4 | 5 | For a service to be federated by Bramble the only requirement is to implement the `Service` type and query: 6 | 7 | ```graphql 8 | type Service { 9 | name: String! # unique name for the service 10 | version: String! # any string 11 | schema: String! # the full schema for the service 12 | } 13 | 14 | type Query { 15 | service: Service! 16 | } 17 | ``` 18 | 19 | !> The `Service` type is only used internally by Bramble and will not be part of the exposed schema. 20 | 21 | ## Configuration 22 | 23 | Create a JSON config file with the following format: 24 | 25 | _config.json_ 26 | 27 | ```json 28 | { 29 | "services": ["http://service1/query", "http://service2/query"] // list of services to federate 30 | } 31 | ``` 32 | 33 | For the full list of available options see [configuration](configuration.md). 34 | 35 | ## Running Bramble 36 | 37 | ### Install locally 38 | 39 | (requires Golang) 40 | 41 | ``` 42 | go install github.com/movio/bramble/cmd/bramble@latest 43 | ``` 44 | 45 | ``` 46 | bramble config.json 47 | ``` 48 | 49 | ### Docker 50 | 51 | ``` 52 | docker run -p 8082:8082 -v $(PWD)/config.json:/config.json ghcr.io/movio/bramble -config config.json 53 | ``` 54 | 55 | ## Querying Bramble 56 | 57 | Bramble can be queried like any GraphQL service, just point your favourite 58 | client to `http://localhost:8082/query`. 59 | -------------------------------------------------------------------------------- /examples/nodejs-service/index.js: -------------------------------------------------------------------------------- 1 | var express = require("express"); 2 | var { graphqlHTTP } = require('express-graphql'); 3 | var { buildSchema } = require("graphql"); 4 | var fs = require("fs").promises; 5 | 6 | const defaultPort = 8080; 7 | class Gizmo { 8 | constructor(id) { 9 | this.id = id; 10 | this.rating = Math.floor(Math.random() * 100); 11 | } 12 | static get(id) { 13 | return new Gizmo(id); 14 | } 15 | } 16 | 17 | async function setup() { 18 | let schemaSource = await fs.readFile("schema.graphql", "utf-8"); 19 | let schema = buildSchema(schemaSource); 20 | 21 | let resolver = { 22 | service: { 23 | name: "nodejs-service", 24 | version: "0.1.0", 25 | schema: schemaSource, 26 | }, 27 | gizmo: (args) => Gizmo.get(args.id), 28 | }; 29 | 30 | let app = express(); 31 | app.use( 32 | "/query", 33 | graphqlHTTP({ 34 | schema: schema, 35 | rootValue: resolver, 36 | graphiql: true, 37 | }) 38 | ); 39 | 40 | app.use('/health', (req, res) => { 41 | res.send('OK') 42 | }); 43 | 44 | return app; 45 | } 46 | 47 | (async () => { 48 | try { 49 | let app = await setup(); 50 | let port = process.env.PORT; 51 | if (port === undefined) { 52 | port = defaultPort; 53 | } 54 | app.listen(port, () => 55 | console.log(`example nodejs-service running on :${port}`) 56 | ); 57 | } catch (e) { 58 | console.log(e); 59 | } 60 | })(); 61 | -------------------------------------------------------------------------------- /plugins/headers.go: -------------------------------------------------------------------------------- 1 | package plugins 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | 7 | "github.com/movio/bramble" 8 | ) 9 | 10 | func init() { 11 | bramble.RegisterPlugin(&HeadersPlugin{}) 12 | } 13 | 14 | type HeadersPlugin struct { 15 | bramble.BasePlugin 16 | config HeadersPluginConfig 17 | } 18 | 19 | type HeadersPluginConfig struct { 20 | AllowedHeaders []string `json:"allowed-headers"` 21 | } 22 | 23 | func NewHeadersPlugin(options HeadersPluginConfig) *HeadersPlugin { 24 | return &HeadersPlugin{bramble.BasePlugin{}, options} 25 | } 26 | 27 | func (p *HeadersPlugin) ID() string { 28 | return "headers" 29 | } 30 | 31 | func (p *HeadersPlugin) Configure(cfg *bramble.Config, data json.RawMessage) error { 32 | return json.Unmarshal(data, &p.config) 33 | } 34 | 35 | func (p *HeadersPlugin) middleware(h http.Handler) http.Handler { 36 | return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { 37 | ctx := r.Context() 38 | for _, header := range p.config.AllowedHeaders { 39 | if value := r.Header.Get(header); value != "" { 40 | ctx = bramble.AddOutgoingRequestsHeaderToContext(ctx, header, value) 41 | } 42 | } 43 | h.ServeHTTP(rw, r.WithContext(ctx)) 44 | }) 45 | } 46 | 47 | func (p *HeadersPlugin) ApplyMiddlewarePublicMux(h http.Handler) http.Handler { 48 | return p.middleware(h) 49 | } 50 | 51 | func (p *HeadersPlugin) ApplyMiddlewarePrivateMux(h http.Handler) http.Handler { 52 | return p.middleware(h) 53 | } 54 | -------------------------------------------------------------------------------- /examples/graph-gophers-service/resolver.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | _ "embed" 5 | "net/http" 6 | 7 | "github.com/go-faker/faker/v4" 8 | "github.com/graph-gophers/graphql-go" 9 | "github.com/graph-gophers/graphql-go/relay" 10 | ) 11 | 12 | var name = "graph-gophers-service" 13 | var version = "0.1.0" 14 | 15 | //go:embed schema.graphql 16 | var schema string 17 | 18 | func newResolver() http.Handler { 19 | return &relay.Handler{ 20 | Schema: graphql.MustParseSchema(schema, &resolver{ 21 | Service: service{ 22 | Name: name, 23 | Version: version, 24 | Schema: schema, 25 | }, 26 | emails: make(map[graphql.ID]string), 27 | }, graphql.UseFieldResolvers())} 28 | } 29 | 30 | type service struct { 31 | Name string 32 | Version string 33 | Schema string 34 | } 35 | 36 | type gizmo struct { 37 | ID graphql.ID 38 | Email string 39 | } 40 | 41 | type resolver struct { 42 | Service service 43 | emails map[graphql.ID]string 44 | } 45 | 46 | func (r *resolver) fetchEmailById(id graphql.ID) string { 47 | email, ok := r.emails[id] 48 | if !ok { 49 | email = faker.Email() 50 | r.emails[id] = email 51 | } 52 | return email 53 | } 54 | 55 | func (r *resolver) Gizmos(args struct { 56 | IDs []graphql.ID 57 | }) ([]*gizmo, error) { 58 | gizmos := []*gizmo{} 59 | for _, id := range args.IDs { 60 | gizmos = append(gizmos, &gizmo{ 61 | ID: id, 62 | Email: r.fetchEmailById(id), 63 | }) 64 | } 65 | return gizmos, nil 66 | } 67 | -------------------------------------------------------------------------------- /plugins/headers_test.go: -------------------------------------------------------------------------------- 1 | package plugins 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | "testing" 7 | 8 | "github.com/movio/bramble" 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | func TestHeaders(t *testing.T) { 13 | p := NewHeadersPlugin(HeadersPluginConfig{ 14 | AllowedHeaders: []string{"X-Fun-Header"}, 15 | }) 16 | 17 | t.Run("unknown header is not in context", func(t *testing.T) { 18 | called := false 19 | handler := p.ApplyMiddlewarePublicMux(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 20 | called = true 21 | headers := bramble.GetOutgoingRequestHeadersFromContext(r.Context()) 22 | assert.Empty(t, headers.Get("X-Bad-Header")) 23 | })) 24 | req := httptest.NewRequest(http.MethodPost, "/query", nil) 25 | req.Header.Add("X-Bad-Header", "bad") 26 | handler.ServeHTTP(httptest.NewRecorder(), req) 27 | assert.True(t, called) 28 | }) 29 | t.Run("allowed header is in context", func(t *testing.T) { 30 | called := false 31 | handler := p.ApplyMiddlewarePublicMux(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 32 | called = true 33 | headers := bramble.GetOutgoingRequestHeadersFromContext(r.Context()) 34 | assert.Equal(t, headers.Get("X-Fun-Header"), "funtime") 35 | })) 36 | req := httptest.NewRequest(http.MethodPost, "/query", nil) 37 | req.Header.Add("X-Fun-Header", "funtime") 38 | handler.ServeHTTP(httptest.NewRecorder(), req) 39 | assert.True(t, called) 40 | }) 41 | } 42 | -------------------------------------------------------------------------------- /examples/gqlgen-multipart-file-upload-service/README.md: -------------------------------------------------------------------------------- 1 | # Example gqlgen based service with multipart file upload 2 | 3 | This is an example service that exposes a very simple mutations: 4 | 5 | uploadGizmoFile(upload: Upload!) String 6 | uploadGadgetFile(upload: GadgetInput!): String 7 | 8 | _Note: we have not added `gqlgen` related generated files to git; must `go generate .` before use_ 9 | 10 | To upload file you can use curl to send file to the gateway: 11 | 12 | ``` 13 | curl --request POST \ 14 | --url http://localhost:8082/query \ 15 | --header 'content-type: multipart/form-data' \ 16 | --form 'operations={"query":"mutation uploadGizmoFile($upload: Upload!) {uploadGizmoFile(upload: $upload)}","variables":{"upload":null},"operationName":"uploadGizmoFile"}' \ 17 | --form 'map={"file1": ["variables.upload"]}' \ 18 | --form 'file1=@"sample_file.txt"' 19 | ``` 20 | 21 | With input type: 22 | 23 | ``` 24 | curl --request POST \ 25 | --url http://localhost:8082/query \ 26 | --header 'Content-Type: multipart/form-data' \ 27 | --form 'operations={"query":"mutation uploadGadgetFile($upload: GadgetInput!) {uploadGadgetFile(upload: $upload)}","variables":{"upload":{"upload": null}},"operationName":"uploadGadgetFile"}' \ 28 | --form 'map={"file1": ["variables.upload.upload"]}' \ 29 | --form 'file1=@"sample_file.txt"' 30 | ``` 31 | 32 | Note: you **must** pass `Content-Type` headers for file upload to work. So add `Content-Type` to `allowed-headers` to `headers` plugin. 33 | -------------------------------------------------------------------------------- /examples/slow-service/resolver.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | _ "embed" 6 | "net/http" 7 | "time" 8 | 9 | "github.com/graph-gophers/graphql-go" 10 | "github.com/graph-gophers/graphql-go/relay" 11 | ) 12 | 13 | var name = "slow-service" 14 | var version = "0.1.0" 15 | 16 | //go:embed schema.graphql 17 | var schema string 18 | 19 | func newResolver() http.Handler { 20 | return &relay.Handler{ 21 | Schema: graphql.MustParseSchema(schema, &resolver{ 22 | Service: service{ 23 | Name: name, 24 | Version: version, 25 | Schema: schema, 26 | }, 27 | emails: make(map[graphql.ID]string), 28 | }, graphql.UseFieldResolvers())} 29 | } 30 | 31 | type service struct { 32 | Name string 33 | Version string 34 | Schema string 35 | } 36 | 37 | type gizmo struct { 38 | ID graphql.ID 39 | } 40 | 41 | func (g *gizmo) Delay(ctx context.Context, args struct { 42 | Duration string 43 | }) (*string, error) { 44 | duration, err := time.ParseDuration(args.Duration) 45 | if err != nil { 46 | return nil, err 47 | } 48 | t := time.Now() 49 | time.Sleep(duration) 50 | dur := time.Since(t).String() 51 | return &dur, nil 52 | } 53 | 54 | type resolver struct { 55 | Service service 56 | emails map[graphql.ID]string 57 | } 58 | 59 | func (r *resolver) Gizmos(args struct { 60 | IDs []graphql.ID 61 | }) ([]*gizmo, error) { 62 | gizmos := []*gizmo{} 63 | for _, id := range args.IDs { 64 | gizmos = append(gizmos, &gizmo{ 65 | ID: id, 66 | }) 67 | } 68 | return gizmos, nil 69 | } 70 | -------------------------------------------------------------------------------- /plugins/admin_ui_test.go: -------------------------------------------------------------------------------- 1 | package plugins 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | "net/url" 7 | "testing" 8 | 9 | "github.com/movio/bramble" 10 | "github.com/stretchr/testify/assert" 11 | "github.com/vektah/gqlparser/v2" 12 | "github.com/vektah/gqlparser/v2/ast" 13 | ) 14 | 15 | func TestAdminUI(t *testing.T) { 16 | plugin := &AdminUIPlugin{} 17 | es := &bramble.ExecutableSchema{ 18 | Services: map[string]*bramble.Service{ 19 | "svc-a": { 20 | Schema: gqlparser.MustLoadSchema(&ast.Source{Input: ``}), 21 | }, 22 | "svc-b": { 23 | Schema: gqlparser.MustLoadSchema(&ast.Source{Input: ``}), 24 | }, 25 | }, 26 | } 27 | plugin.Init(es) 28 | m := http.NewServeMux() 29 | plugin.SetupPrivateMux(m) 30 | 31 | t.Run("test valid schema", func(t *testing.T) { 32 | req := httptest.NewRequest(http.MethodPost, "/admin", nil) 33 | req.Form = url.Values{ 34 | "schema": []string{` 35 | type Service { 36 | name: String! 37 | version: String! 38 | schema: String! 39 | } 40 | type Query { service: Service! }`}, 41 | } 42 | rr := httptest.NewRecorder() 43 | m.ServeHTTP(rr, req) 44 | 45 | assert.Contains(t, rr.Body.String(), "Schema merged successfully") 46 | }) 47 | 48 | t.Run("test invalid schema", func(t *testing.T) { 49 | req := httptest.NewRequest(http.MethodPost, "/admin", nil) 50 | req.Form = url.Values{ 51 | "schema": []string{`type Query { foo: Bar! }`}, 52 | } 53 | rr := httptest.NewRecorder() 54 | m.ServeHTTP(rr, req) 55 | 56 | assert.NotContains(t, rr.Body.String(), "Schema merged successfully") 57 | }) 58 | } 59 | -------------------------------------------------------------------------------- /examples/gqlgen-service/resolver.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | _ "embed" 6 | "fmt" 7 | "net/http" 8 | 9 | "github.com/99designs/gqlgen/graphql" 10 | "github.com/99designs/gqlgen/graphql/handler" 11 | ) 12 | 13 | var name = "gqlgen-service" 14 | var version = "0.1.0" 15 | 16 | //go:embed schema.graphql 17 | var schema string 18 | 19 | func newResolver() http.Handler { 20 | c := Config{ 21 | Resolvers: &Resolver{ 22 | gizmos: generateGizmos(), 23 | service: Service{ 24 | Name: name, 25 | Version: version, 26 | Schema: schema, 27 | }, 28 | }, 29 | Directives: DirectiveRoot{ 30 | // Support the @boundary directive as a no-op 31 | Boundary: func(ctx context.Context, obj interface{}, next graphql.Resolver) (res interface{}, err error) { 32 | return next(ctx) 33 | }, 34 | }, 35 | } 36 | return handler.NewDefaultServer(NewExecutableSchema(c)) 37 | } 38 | 39 | type Resolver struct { 40 | gizmos map[string]*Gizmo 41 | service Service 42 | } 43 | 44 | func (r *Resolver) Query() QueryResolver { 45 | return r 46 | } 47 | 48 | func (r *Resolver) Service(ctx context.Context) (*Service, error) { 49 | return &r.service, nil 50 | } 51 | 52 | func (r *Resolver) Gizmo(ctx context.Context, id string) (*Gizmo, error) { 53 | if gizmo, ok := r.gizmos[id]; ok { 54 | return gizmo, nil 55 | } 56 | return nil, fmt.Errorf("no gizmo found with id: %s", id) 57 | } 58 | 59 | func (r *Resolver) RandomGizmo(ctx context.Context) (*Gizmo, error) { 60 | for _, gizmo := range r.gizmos { 61 | return gizmo, nil 62 | } 63 | return nil, fmt.Errorf("failed to find a gizmo") 64 | } 65 | -------------------------------------------------------------------------------- /examples/slow-service/go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 2 | github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= 3 | github.com/go-logr/logr v1.2.3/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= 4 | github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= 5 | github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE= 6 | github.com/graph-gophers/graphql-go v1.4.0 h1:JE9wveRTSXwJyjdRd6bOQ7Ob5bewTUQ58Jv4OiVdpdE= 7 | github.com/graph-gophers/graphql-go v1.4.0/go.mod h1:YtmJZDLbF1YYNrlNAuiO5zAStUWc3XZT07iGsVqe1Os= 8 | github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc= 9 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 10 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 11 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 12 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 13 | go.opentelemetry.io/otel v1.6.3/go.mod h1:7BgNga5fNlF/iZjG06hM3yofffp0ofKCDwSXx1GC4dI= 14 | go.opentelemetry.io/otel/trace v1.6.3/go.mod h1:GNJQusJlUgZl9/TQBPKU/Y/ty+0iVB5fjhKeJGZPGFs= 15 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 16 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 17 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 18 | -------------------------------------------------------------------------------- /examples/gqlgen-multipart-file-upload-service/resolver.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | _ "embed" 6 | "fmt" 7 | "net/http" 8 | 9 | "github.com/99designs/gqlgen/graphql" 10 | "github.com/99designs/gqlgen/graphql/handler" 11 | ) 12 | 13 | var name = "gqlgen-service" 14 | var version = "0.1.0" 15 | 16 | //go:embed schema.graphql 17 | var schema string 18 | 19 | func newResolver() http.Handler { 20 | c := Config{ 21 | Resolvers: &Resolver{ 22 | service: Service{ 23 | Name: name, 24 | Version: version, 25 | Schema: schema, 26 | }, 27 | }, 28 | Directives: DirectiveRoot{ 29 | // Support the @boundary directive as a no-op 30 | Boundary: func(ctx context.Context, obj interface{}, next graphql.Resolver) (res interface{}, err error) { 31 | return next(ctx) 32 | }, 33 | }, 34 | } 35 | return handler.NewDefaultServer(NewExecutableSchema(c)) 36 | } 37 | 38 | type Resolver struct { 39 | service Service 40 | } 41 | 42 | func (r *Resolver) Query() QueryResolver { 43 | return r 44 | } 45 | 46 | func (r *Resolver) Mutation() MutationResolver { 47 | return r 48 | } 49 | 50 | func (r *Resolver) Service(ctx context.Context) (*Service, error) { 51 | return &r.service, nil 52 | } 53 | 54 | func (r *Resolver) UploadGizmoFile(ctx context.Context, upload graphql.Upload) (string, error) { 55 | return fmt.Sprintf("%s: %d bytes %s", upload.Filename, upload.Size, upload.ContentType), nil 56 | } 57 | func (r *Resolver) UploadGadgetFile(ctx context.Context, input GadgetInput) (string, error) { 58 | upload := input.Upload 59 | return fmt.Sprintf("%s: %d bytes %s", upload.Filename, upload.Size, upload.ContentType), nil 60 | } 61 | -------------------------------------------------------------------------------- /context.go: -------------------------------------------------------------------------------- 1 | package bramble 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | ) 7 | 8 | type contextKey string 9 | type brambleContextKey int 10 | 11 | const permissionsContextKey brambleContextKey = 1 12 | const requestHeaderContextKey brambleContextKey = 2 13 | 14 | // AddPermissionsToContext adds permissions to the request context. If 15 | // permissions are set the execution will check them against the query. 16 | func AddPermissionsToContext(ctx context.Context, perms OperationPermissions) context.Context { 17 | return context.WithValue(ctx, permissionsContextKey, perms) 18 | } 19 | 20 | // GetPermissionsFromContext returns the permissions stored in the context 21 | func GetPermissionsFromContext(ctx context.Context) (OperationPermissions, bool) { 22 | v := ctx.Value(permissionsContextKey) 23 | if v == nil { 24 | return OperationPermissions{}, false 25 | } 26 | 27 | if perm, ok := v.(OperationPermissions); ok { 28 | return perm, true 29 | } 30 | 31 | return OperationPermissions{}, false 32 | } 33 | 34 | // AddOutgoingRequestsHeaderToContext adds a header to all outgoings requests for the current query 35 | func AddOutgoingRequestsHeaderToContext(ctx context.Context, key, value string) context.Context { 36 | h, ok := ctx.Value(requestHeaderContextKey).(http.Header) 37 | if !ok { 38 | h = make(http.Header) 39 | } 40 | h.Add(key, value) 41 | 42 | return context.WithValue(ctx, requestHeaderContextKey, h) 43 | } 44 | 45 | // GetOutgoingRequestHeadersFromContext get the headers that should be added to outgoing requests 46 | func GetOutgoingRequestHeadersFromContext(ctx context.Context) http.Header { 47 | h, _ := ctx.Value(requestHeaderContextKey).(http.Header) 48 | return h 49 | } 50 | -------------------------------------------------------------------------------- /.mailmap: -------------------------------------------------------------------------------- 1 | Adam Sven Johnson 2 | Anar Khalilov 3 | Andy Yu 4 | Azad Asanali 5 | Chris Day 6 | David Jameson 7 | dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 8 | Felipe Bustamante 9 | Florian Suess 10 | Greg MacWilliam 11 | Hasibul Hasan hsblhsn 12 | Hayden Woodhead <21234170+haydenwoodhead@users.noreply.github.com> 13 | Hays Clark 14 | Holger Lösken 15 | jainpiyush19 16 | Jean Pasdeloup 17 | Joshua Fu <42632439+joshiefu@users.noreply.github.com> Joshua <42632439+joshiefu@users.noreply.github.com> 18 | karatekaneen <41196840+karatekaneen@users.noreply.github.com> 19 | Leon Huston LeonHuston 20 | Lucian Jones 21 | Lucian Jones 22 | Malcolm Lockyer 23 | Nicolas Maquet 24 | Philip Müller 25 | Pi Lanningham 26 | Piyush Jain <14311837+jainpiyush19@users.noreply.github.com> 27 | Rob Rohan 28 | Robin 29 | Roman A. Grigorovich 30 | Sebastian Rickelt 31 | Sylvain Cleymans 32 | -------------------------------------------------------------------------------- /plugins/limits.go: -------------------------------------------------------------------------------- 1 | package plugins 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/http" 7 | "time" 8 | 9 | "github.com/movio/bramble" 10 | ) 11 | 12 | func init() { 13 | bramble.RegisterPlugin(&LimitsPlugin{}) 14 | } 15 | 16 | type LimitsPlugin struct { 17 | bramble.BasePlugin 18 | config LimitsPluginConfig 19 | } 20 | 21 | type LimitsPluginConfig struct { 22 | MaxRequestBytes int64 `json:"max-request-bytes"` 23 | MaxResponseTime string `json:"max-response-time"` 24 | maxResponseDuration time.Duration 25 | } 26 | 27 | func NewLimitsPlugin(options LimitsPluginConfig) *LimitsPlugin { 28 | return &LimitsPlugin{bramble.BasePlugin{}, options} 29 | } 30 | 31 | func (p *LimitsPlugin) ID() string { 32 | return "limits" 33 | } 34 | 35 | func (p *LimitsPlugin) Init(es *bramble.ExecutableSchema) { 36 | es.GraphqlClient.HTTPClient.Timeout = p.config.maxResponseDuration 37 | } 38 | 39 | func (p *LimitsPlugin) Configure(cfg *bramble.Config, data json.RawMessage) error { 40 | err := json.Unmarshal(data, &p.config) 41 | if err != nil { 42 | return err 43 | } 44 | 45 | if p.config.MaxRequestBytes == 0 { 46 | return fmt.Errorf("MaxRequestBytes is undefined") 47 | } 48 | 49 | if p.config.MaxResponseTime == "" { 50 | return fmt.Errorf("MaxResponseTime is undefined") 51 | } 52 | 53 | p.config.maxResponseDuration, err = time.ParseDuration(p.config.MaxResponseTime) 54 | if err != nil { 55 | return fmt.Errorf("invalid duration: %w", err) 56 | } 57 | 58 | return nil 59 | } 60 | 61 | func (p *LimitsPlugin) ApplyMiddlewarePublicMux(h http.Handler) http.Handler { 62 | handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 63 | r.Body = http.MaxBytesReader(w, r.Body, p.config.MaxRequestBytes) 64 | h.ServeHTTP(w, r) 65 | }) 66 | return handler 67 | } 68 | -------------------------------------------------------------------------------- /config_test.go: -------------------------------------------------------------------------------- 1 | package bramble 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/stretchr/testify/require" 8 | ) 9 | 10 | func TestConfig(t *testing.T) { 11 | t.Run("port provided", func(t *testing.T) { 12 | cfg := &Config{ 13 | GatewayPort: 8082, 14 | PrivatePort: 8083, 15 | MetricsPort: 8084, 16 | } 17 | require.Equal(t, ":8082", cfg.GatewayAddress()) 18 | require.Equal(t, ":8083", cfg.PrivateAddress()) 19 | require.Equal(t, ":8084", cfg.MetricAddress()) 20 | }) 21 | t.Run("address provided and prefered over port", func(t *testing.T) { 22 | cfg := &Config{ 23 | GatewayListenAddress: "0.0.0.0:8082", 24 | GatewayPort: 0, 25 | PrivateListenAddress: "127.0.0.1:8084", 26 | PrivatePort: 8083, 27 | MetricsListenAddress: "", 28 | MetricsPort: 8084, 29 | } 30 | require.Equal(t, "0.0.0.0:8082", cfg.GatewayAddress()) 31 | require.Equal(t, "127.0.0.1:8084", cfg.PrivateAddress()) 32 | require.Equal(t, ":8084", cfg.MetricAddress()) 33 | }) 34 | t.Run("private http address for plugin services", func(t *testing.T) { 35 | cfg := &Config{ 36 | PrivatePort: 8083, 37 | } 38 | require.Equal(t, "http://localhost:8083/plugin", cfg.PrivateHttpAddress("plugin")) 39 | cfg.PrivateListenAddress = "127.0.0.1:8084" 40 | require.Equal(t, "http://127.0.0.1:8084/plugin", cfg.PrivateHttpAddress("plugin")) 41 | }) 42 | } 43 | 44 | func TestParseExampleConfig(t *testing.T) { 45 | cfg, err := GetConfig([]string{"config.json.example"}) 46 | require.NoError(t, err) 47 | require.Equal(t, 5*time.Second, cfg.DefaultTimeouts.ReadTimeoutDuration) 48 | require.Equal(t, 120*time.Second, cfg.DefaultTimeouts.IdleTimeoutDuration) 49 | require.Equal(t, 20*time.Second, cfg.GatewayTimeouts.WriteTimeoutDuration) 50 | require.Equal(t, 10*time.Second, cfg.PrivateTimeouts.WriteTimeoutDuration) 51 | } 52 | -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | gqlgen-service: 3 | build: 4 | context: examples/gqlgen-service 5 | healthcheck: &healthcheck 6 | test: wget -qO - http://localhost:8080/health 7 | interval: 5s 8 | timeout: 1s 9 | retries: 5 10 | expose: 11 | - 8080 12 | gqlgen-multipart-file-upload-service: 13 | build: 14 | context: examples/gqlgen-multipart-file-upload-service 15 | healthcheck: &healthcheck 16 | test: wget -qO - http://localhost:8080/health 17 | interval: 5s 18 | timeout: 1s 19 | retries: 5 20 | expose: 21 | - 8080 22 | graph-gophers-service: 23 | healthcheck: *healthcheck 24 | build: 25 | context: examples/graph-gophers-service 26 | expose: 27 | - 8080 28 | slow-service: 29 | healthcheck: *healthcheck 30 | build: 31 | context: examples/slow-service 32 | expose: 33 | - 8080 34 | nodejs-service: 35 | healthcheck: *healthcheck 36 | build: 37 | context: examples/nodejs-service 38 | expose: 39 | - 8080 40 | gateway: 41 | build: 42 | context: . 43 | configs: [gateway] 44 | command: ["-config", "gateway", "-loglevel", "debug"] 45 | environment: 46 | - BRAMBLE_SERVICE_LIST=http://gqlgen-service:8080/query http://gqlgen-multipart-file-upload-service:8080/query http://graph-gophers-service:8080/query http://slow-service:8080/query http://nodejs-service:8080/query 47 | ports: 48 | - 8082:8082 49 | - 8083:8083 50 | - 9009:9009 51 | depends_on: 52 | gqlgen-service: 53 | condition: service_healthy 54 | graph-gophers-service: 55 | condition: service_healthy 56 | slow-service: 57 | condition: service_healthy 58 | nodejs-service: 59 | condition: service_healthy 60 | configs: 61 | gateway: 62 | file: ./examples/gateway.json 63 | -------------------------------------------------------------------------------- /plugins/cors.go: -------------------------------------------------------------------------------- 1 | package plugins 2 | 3 | import ( 4 | "encoding/json" 5 | "log/slog" 6 | "net/http" 7 | 8 | "github.com/movio/bramble" 9 | "github.com/rs/cors" 10 | ) 11 | 12 | func init() { 13 | bramble.RegisterPlugin(&CorsPlugin{}) 14 | } 15 | 16 | type CorsPlugin struct { 17 | bramble.BasePlugin 18 | config CorsPluginConfig 19 | } 20 | 21 | type CorsPluginConfig struct { 22 | AllowedOrigins []string `json:"allowed-origins"` 23 | AllowedHeaders []string `json:"allowed-headers"` 24 | AllowedMethods []string `json:"allowed-methods"` 25 | AllowCredentials bool `json:"allow-credentials"` 26 | ExposedHeaders []string `json:"exposed-headers"` 27 | MaxAge int `json:"max-age"` 28 | Debug bool `json:"debug"` 29 | } 30 | 31 | func NewCorsPlugin(options CorsPluginConfig) *CorsPlugin { 32 | return &CorsPlugin{bramble.BasePlugin{}, options} 33 | } 34 | 35 | func (p *CorsPlugin) ID() string { 36 | return "cors" 37 | } 38 | 39 | func (p *CorsPlugin) Configure(cfg *bramble.Config, data json.RawMessage) error { 40 | return json.Unmarshal(data, &p.config) 41 | } 42 | 43 | func (p *CorsPlugin) middleware(h http.Handler) http.Handler { 44 | c := cors.New(cors.Options{ 45 | AllowedOrigins: p.config.AllowedOrigins, 46 | AllowedHeaders: p.config.AllowedHeaders, 47 | AllowedMethods: p.config.AllowedMethods, 48 | AllowCredentials: p.config.AllowCredentials, 49 | ExposedHeaders: p.config.ExposedHeaders, 50 | MaxAge: p.config.MaxAge, 51 | Debug: p.config.Debug, 52 | }) 53 | if p.config.Debug { 54 | c.Log = slog.NewLogLogger(slog.Default().Handler(), slog.LevelInfo) 55 | } 56 | return c.Handler(h) 57 | } 58 | 59 | func (p *CorsPlugin) ApplyMiddlewarePublicMux(h http.Handler) http.Handler { 60 | return p.middleware(h) 61 | } 62 | 63 | func (p *CorsPlugin) ApplyMiddlewarePrivateMux(h http.Handler) http.Handler { 64 | return p.middleware(h) 65 | } 66 | -------------------------------------------------------------------------------- /examples/graph-gophers-service/go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 2 | github.com/go-faker/faker/v4 v4.0.0-beta.3 h1:zjTxJMHn7Po7OCPKY+VjO6mNQ4ZzE7PoBjb2sUNHVPs= 3 | github.com/go-faker/faker/v4 v4.0.0-beta.3/go.mod h1:uuNc0PSRxF8nMgjGrrrU4Nw5cF30Jc6Kd0/FUTTYbhg= 4 | github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= 5 | github.com/go-logr/logr v1.2.3/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= 6 | github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= 7 | github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE= 8 | github.com/graph-gophers/graphql-go v1.4.0 h1:JE9wveRTSXwJyjdRd6bOQ7Ob5bewTUQ58Jv4OiVdpdE= 9 | github.com/graph-gophers/graphql-go v1.4.0/go.mod h1:YtmJZDLbF1YYNrlNAuiO5zAStUWc3XZT07iGsVqe1Os= 10 | github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc= 11 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 12 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 13 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 14 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 15 | go.opentelemetry.io/otel v1.6.3/go.mod h1:7BgNga5fNlF/iZjG06hM3yofffp0ofKCDwSXx1GC4dI= 16 | go.opentelemetry.io/otel/trace v1.6.3/go.mod h1:GNJQusJlUgZl9/TQBPKU/Y/ty+0iVB5fjhKeJGZPGFs= 17 | golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= 18 | golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= 19 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 20 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 21 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 22 | -------------------------------------------------------------------------------- /examples/bramble-examples.postman_collection.json: -------------------------------------------------------------------------------- 1 | { 2 | "info": { 3 | "_postman_id": "9f400ac1-c634-472b-9cfd-93fcf9d5f21a", 4 | "name": "Bramble examples", 5 | "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", 6 | "_exporter_id": "2117581" 7 | }, 8 | "item": [ 9 | { 10 | "name": "Random Gizmo", 11 | "event": [ 12 | { 13 | "listen": "test", 14 | "script": { 15 | "exec": [ 16 | "" 17 | ], 18 | "type": "text/javascript", 19 | "packages": {} 20 | } 21 | } 22 | ], 23 | "request": { 24 | "method": "POST", 25 | "header": [], 26 | "body": { 27 | "mode": "graphql", 28 | "graphql": { 29 | "query": "query gizmo($duration: String! = \"10ms\") {\n randomGizmo {\n id\n name\n email\n delay(duration: $duration)\n }\n}", 30 | "variables": "{\n \"duration\": \"150ms\"\n}" 31 | } 32 | }, 33 | "url": { 34 | "raw": "http://localhost:8082/query", 35 | "protocol": "http", 36 | "host": [ 37 | "localhost" 38 | ], 39 | "port": "8082", 40 | "path": [ 41 | "query" 42 | ] 43 | } 44 | }, 45 | "response": [] 46 | }, 47 | { 48 | "name": "File upload example", 49 | "request": { 50 | "method": "POST", 51 | "header": [], 52 | "body": { 53 | "mode": "formdata", 54 | "formdata": [ 55 | { 56 | "key": "operations", 57 | "value": "{\"query\":\"mutation uploadGizmoFile($upload: Upload!) {uploadGizmoFile(upload: $upload)}\",\"variables\":{\"upload\":null},\"operationName\":\"uploadGizmoFile\"}", 58 | "description": "GraphQL query to upload file", 59 | "type": "text" 60 | }, 61 | { 62 | "key": "map", 63 | "value": "{\"file1\":[\"variables.upload\"]}", 64 | "description": "file part to variable map", 65 | "type": "text" 66 | }, 67 | { 68 | "key": "file1", 69 | "description": "the contents of file1", 70 | "type": "file", 71 | "src": [] 72 | } 73 | ] 74 | }, 75 | "url": { 76 | "raw": "http://localhost:8082/query", 77 | "protocol": "http", 78 | "host": [ 79 | "localhost" 80 | ], 81 | "port": "8082", 82 | "path": [ 83 | "query" 84 | ] 85 | } 86 | }, 87 | "response": [] 88 | } 89 | ] 90 | } -------------------------------------------------------------------------------- /instrumentation.go: -------------------------------------------------------------------------------- 1 | package bramble 2 | 3 | import ( 4 | "context" 5 | log "log/slog" 6 | "sync" 7 | "time" 8 | ) 9 | 10 | const eventKey contextKey = "instrumentation" 11 | 12 | type event struct { 13 | nameFunc EventNameFunc 14 | timestamp time.Time 15 | fields EventFields 16 | fieldLock sync.Mutex 17 | writeLock sync.Once 18 | } 19 | 20 | // EventFields contains fields to be logged for the event 21 | type EventFields map[string]interface{} 22 | 23 | // EventNameFunc constructs a name for the event from the provided fields 24 | type EventNameFunc func(EventFields) string 25 | 26 | func newEvent(name EventNameFunc) *event { 27 | return &event{ 28 | nameFunc: name, 29 | timestamp: time.Now(), 30 | fields: EventFields{}, 31 | } 32 | } 33 | 34 | func startEvent(ctx context.Context, name EventNameFunc) (context.Context, *event) { 35 | ev := newEvent(name) 36 | return context.WithValue(ctx, eventKey, ev), ev 37 | } 38 | 39 | func (e *event) addField(name string, value interface{}) { 40 | e.fieldLock.Lock() 41 | e.fields[name] = value 42 | e.fieldLock.Unlock() 43 | } 44 | 45 | func (e *event) addFields(fields EventFields) { 46 | e.fieldLock.Lock() 47 | for k, v := range fields { 48 | e.fields[k] = v 49 | } 50 | e.fieldLock.Unlock() 51 | } 52 | 53 | func (e *event) debugEnabled() bool { 54 | return log.Default().Enabled(context.Background(), log.LevelDebug) 55 | } 56 | 57 | func (e *event) finish() { 58 | e.writeLock.Do(func() { 59 | attrs := make([]any, 0, len(e.fields)) 60 | for k, v := range e.fields { 61 | attrs = append(attrs, log.Any(k, v)) 62 | } 63 | log.With( 64 | "duration", time.Since(e.timestamp).String(), 65 | ).Info(e.nameFunc(e.fields), attrs...) 66 | }) 67 | } 68 | 69 | // AddField adds the given field to the event contained in the context (if any) 70 | func AddField(ctx context.Context, name string, value interface{}) { 71 | if e := getEvent(ctx); e != nil { 72 | e.addField(name, value) 73 | } 74 | } 75 | 76 | // AddFields adds the given fields to the event contained in the context (if any) 77 | func AddFields(ctx context.Context, fields EventFields) { 78 | if e := getEvent(ctx); e != nil { 79 | e.addFields(fields) 80 | } 81 | } 82 | 83 | func getEvent(ctx context.Context) *event { 84 | if e := ctx.Value(eventKey); e != nil { 85 | if e, ok := e.(*event); ok { 86 | return e 87 | } 88 | } 89 | return nil 90 | } 91 | -------------------------------------------------------------------------------- /testsrv/gizmo_test_server.go: -------------------------------------------------------------------------------- 1 | package testsrv 2 | 3 | import ( 4 | "errors" 5 | "net/http/httptest" 6 | 7 | "github.com/graph-gophers/graphql-go" 8 | "github.com/graph-gophers/graphql-go/relay" 9 | ) 10 | 11 | type service struct { 12 | Name string 13 | Version string 14 | Schema string 15 | } 16 | 17 | type gizmo struct { 18 | IDField string 19 | NameField string 20 | } 21 | 22 | func (u gizmo) ID() graphql.ID { 23 | return graphql.ID(u.IDField) 24 | } 25 | 26 | func (u gizmo) Name() string { 27 | return u.NameField 28 | } 29 | 30 | var gizmos = []*gizmo{ 31 | { 32 | IDField: "GIZMO1", 33 | NameField: "Gizmo #1", 34 | }, 35 | { 36 | IDField: "GIZMO2", 37 | NameField: "Gizmo #2", 38 | }, 39 | { 40 | IDField: "GIZMO3", 41 | NameField: "Gizmo #3", 42 | }, 43 | } 44 | 45 | var gizmosMap = make(map[string]*gizmo) 46 | 47 | func init() { 48 | for _, gizmo := range gizmos { 49 | gizmosMap[gizmo.IDField] = gizmo 50 | } 51 | } 52 | 53 | type gizmoServiceResolver struct { 54 | serviceField service 55 | } 56 | 57 | func (g *gizmoServiceResolver) Service() service { 58 | return g.serviceField 59 | } 60 | 61 | func (g *gizmoServiceResolver) Gizmo(args struct{ ID string }) (*gizmo, error) { 62 | gizmo, ok := gizmosMap[args.ID] 63 | if !ok { 64 | return nil, errors.New("not found") 65 | } 66 | return gizmo, nil 67 | } 68 | 69 | func (g *gizmoServiceResolver) BoundaryGizmo(args struct{ ID string }) (*gizmo, error) { 70 | gizmo, ok := gizmosMap[args.ID] 71 | if !ok { 72 | return nil, errors.New("not found") 73 | } 74 | return gizmo, nil 75 | } 76 | 77 | func NewGizmoService() *httptest.Server { 78 | s := ` 79 | directive @boundary on OBJECT | FIELD_DEFINITION 80 | 81 | type Query { 82 | service: Service! 83 | gizmo(id: ID!): Gizmo! 84 | boundaryGizmo(id: ID!): Gizmo @boundary 85 | } 86 | 87 | type Gizmo @boundary { 88 | id: ID! 89 | name: String! 90 | } 91 | 92 | type Service { 93 | name: String! 94 | version: String! 95 | schema: String! 96 | }` 97 | 98 | schema := graphql.MustParseSchema(s, &gizmoServiceResolver{ 99 | serviceField: service{ 100 | Name: "gizmo-service", 101 | Version: "v0.0.1", 102 | Schema: s, 103 | }, 104 | }, graphql.UseFieldResolvers()) 105 | 106 | return httptest.NewServer(&relay.Handler{Schema: schema}) 107 | } 108 | -------------------------------------------------------------------------------- /plugins/request_id_test.go: -------------------------------------------------------------------------------- 1 | package plugins 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | "strings" 7 | "testing" 8 | 9 | "github.com/gofrs/uuid" 10 | "github.com/movio/bramble" 11 | "github.com/stretchr/testify/assert" 12 | ) 13 | 14 | func TestReqestIdHeader(t *testing.T) { 15 | p := RequestIdentifierPlugin{} 16 | 17 | t.Run("request id is added to outgoing context when not provided", func(t *testing.T) { 18 | called := false 19 | handler := p.ApplyMiddlewarePublicMux(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 20 | called = true 21 | headers := bramble.GetOutgoingRequestHeadersFromContext(r.Context()) 22 | reqID := headers.Get(BrambleRequestHeader) 23 | assert.NotEmpty(t, reqID) 24 | id, err := uuid.FromString(reqID) 25 | assert.NoError(t, err) 26 | assert.True(t, id.Version() == uuid.V4) 27 | })) 28 | req := httptest.NewRequest(http.MethodPost, "/query", nil) 29 | handler.ServeHTTP(httptest.NewRecorder(), req) 30 | assert.True(t, called) 31 | }) 32 | t.Run("request id is passed to outgoing context when provided", func(t *testing.T) { 33 | called := false 34 | handler := p.ApplyMiddlewarePublicMux(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 35 | called = true 36 | headers := bramble.GetOutgoingRequestHeadersFromContext(r.Context()) 37 | reqID := headers.Get(BrambleRequestHeader) 38 | assert.NotEmpty(t, reqID) 39 | id, err := uuid.FromString(reqID) 40 | assert.NoError(t, err) 41 | assert.True(t, id.Version() == uuid.V4) 42 | })) 43 | req := httptest.NewRequest(http.MethodPost, "/query", nil) 44 | req.Header.Add(BrambleRequestHeader, uuid.Must(uuid.NewV4()).String()) 45 | handler.ServeHTTP(httptest.NewRecorder(), req) 46 | assert.True(t, called) 47 | }) 48 | t.Run("request id is reformatted when parseable as a UUID", func(t *testing.T) { 49 | called := false 50 | reqID := uuid.Must(uuid.NewV4()).String() 51 | handler := p.ApplyMiddlewarePublicMux(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 52 | called = true 53 | headers := bramble.GetOutgoingRequestHeadersFromContext(r.Context()) 54 | id := headers.Get(BrambleRequestHeader) 55 | assert.Equal(t, reqID, id) 56 | })) 57 | req := httptest.NewRequest(http.MethodPost, "/query", nil) 58 | req.Header.Add(BrambleRequestHeader, strings.ReplaceAll(reqID, "-", "")) 59 | handler.ServeHTTP(httptest.NewRecorder(), req) 60 | assert.True(t, called) 61 | }) 62 | } 63 | -------------------------------------------------------------------------------- /introspection.go: -------------------------------------------------------------------------------- 1 | package bramble 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/vektah/gqlparser/v2" 8 | "github.com/vektah/gqlparser/v2/ast" 9 | "go.opentelemetry.io/otel" 10 | "go.opentelemetry.io/otel/attribute" 11 | semconv "go.opentelemetry.io/otel/semconv/v1.17.0" 12 | "go.opentelemetry.io/otel/trace" 13 | ) 14 | 15 | // Service is a federated service. 16 | type Service struct { 17 | ServiceURL string 18 | Name string 19 | Version string 20 | SchemaSource string 21 | Schema *ast.Schema 22 | Status string 23 | 24 | tracer trace.Tracer 25 | client *GraphQLClient 26 | } 27 | 28 | // NewService returns a new Service. 29 | func NewService(serviceURL string, opts ...ClientOpt) *Service { 30 | opts = append(opts, WithUserAgent(GenerateUserAgent("update"))) 31 | s := &Service{ 32 | ServiceURL: serviceURL, 33 | tracer: otel.GetTracerProvider().Tracer(instrumentationName), 34 | client: NewClientWithoutKeepAlive(opts...), 35 | } 36 | return s 37 | } 38 | 39 | // Update queries the service's schema, name and version and updates its status. 40 | func (s *Service) Update(ctx context.Context) (bool, error) { 41 | req := NewRequest("query brambleServicePoll { service { name, version, schema} }"). 42 | WithOperationName("brambleServicePoll") 43 | 44 | ctx, span := s.tracer.Start(ctx, "Federated Service Schema Update", 45 | trace.WithSpanKind(trace.SpanKindInternal), 46 | trace.WithAttributes( 47 | semconv.GraphqlOperationTypeQuery, 48 | semconv.GraphqlOperationName(req.OperationName), 49 | semconv.GraphqlDocument(req.Query), 50 | attribute.String("graphql.federation.service", s.Name), 51 | ), 52 | ) 53 | 54 | defer span.End() 55 | 56 | response := struct { 57 | Service struct { 58 | Name string `json:"name"` 59 | Version string `json:"version"` 60 | Schema string `json:"schema"` 61 | } `json:"service"` 62 | }{} 63 | 64 | if err := s.client.Request(ctx, s.ServiceURL, req, &response); err != nil { 65 | s.SchemaSource = "" 66 | s.Status = "Unreachable" 67 | return false, err 68 | } 69 | 70 | updated := response.Service.Schema != s.SchemaSource 71 | 72 | s.Name = response.Service.Name 73 | s.Version = response.Service.Version 74 | s.SchemaSource = response.Service.Schema 75 | 76 | schema, err := gqlparser.LoadSchema(&ast.Source{Name: s.ServiceURL, Input: response.Service.Schema}) 77 | if err != nil { 78 | s.Status = "Schema error" 79 | return false, err 80 | } 81 | s.Schema = schema 82 | 83 | if err := ValidateSchema(s.Schema); err != nil { 84 | s.Status = fmt.Sprintf("Invalid (%s)", err) 85 | return updated, err 86 | } 87 | 88 | s.Status = "OK" 89 | return updated, nil 90 | } 91 | -------------------------------------------------------------------------------- /gateway.go: -------------------------------------------------------------------------------- 1 | package bramble 2 | 3 | import ( 4 | "context" 5 | log "log/slog" 6 | "net/http" 7 | "time" 8 | 9 | "github.com/99designs/gqlgen/graphql/handler" 10 | "github.com/99designs/gqlgen/graphql/handler/extension" 11 | "github.com/99designs/gqlgen/graphql/handler/transport" 12 | "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" 13 | ) 14 | 15 | // Gateway contains the public and private routers 16 | type Gateway struct { 17 | ExecutableSchema *ExecutableSchema 18 | 19 | plugins []Plugin 20 | } 21 | 22 | // NewGateway returns the graphql gateway server mux 23 | func NewGateway(executableSchema *ExecutableSchema, plugins []Plugin) *Gateway { 24 | return &Gateway{ 25 | ExecutableSchema: executableSchema, 26 | plugins: plugins, 27 | } 28 | } 29 | 30 | // UpdateSchemas periodically updates the execute schema 31 | func (g *Gateway) UpdateSchemas(interval time.Duration) { 32 | time.Sleep(interval) 33 | for range time.Tick(interval) { 34 | err := g.ExecutableSchema.UpdateSchema(context.Background(), false) 35 | if err != nil { 36 | log.With("error", err).Error("failed updating schemas") 37 | } 38 | } 39 | } 40 | 41 | // Router returns the public http handler 42 | func (g *Gateway) Router(cfg *Config) http.Handler { 43 | mux := http.NewServeMux() 44 | 45 | gatewayHandler := handler.New(g.ExecutableSchema) 46 | for _, plugin := range g.plugins { 47 | plugin.SetupGatewayHandler(gatewayHandler) 48 | } 49 | // Duplicated from `handler.NewDefaultServer` minus 50 | // the websocket transport and persisted query extension 51 | gatewayHandler.AddTransport(transport.Options{}) 52 | gatewayHandler.AddTransport(transport.GET{}) 53 | gatewayHandler.AddTransport(transport.POST{}) 54 | gatewayHandler.AddTransport(transport.MultipartForm{ 55 | MaxUploadSize: cfg.MaxFileUploadSize, 56 | }) 57 | if !cfg.DisableIntrospection { 58 | gatewayHandler.Use(extension.Introspection{}) 59 | } 60 | 61 | mux.Handle("/query", applyMiddleware(otelhttp.NewHandler(gatewayHandler, "/query"), debugMiddleware)) 62 | 63 | for _, plugin := range g.plugins { 64 | plugin.SetupPublicMux(mux) 65 | } 66 | 67 | var result http.Handler = mux 68 | 69 | for i := len(g.plugins) - 1; i >= 0; i-- { 70 | result = g.plugins[i].ApplyMiddlewarePublicMux(result) 71 | } 72 | 73 | return applyMiddleware(result, monitoringMiddleware) 74 | } 75 | 76 | // PrivateRouter returns the private http handler 77 | func (g *Gateway) PrivateRouter() http.Handler { 78 | mux := http.NewServeMux() 79 | 80 | for _, plugin := range g.plugins { 81 | plugin.SetupPrivateMux(mux) 82 | } 83 | 84 | var result http.Handler = mux 85 | for i := len(g.plugins) - 1; i >= 0; i-- { 86 | result = g.plugins[i].ApplyMiddlewarePrivateMux(result) 87 | } 88 | 89 | return result 90 | } 91 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # Bramble 2 | 3 | Bramble is a production-ready GraphQL federation gateway. 4 | It is built to be a simple, reliable and scalable way to aggregate GraphQL services together. 5 | 6 | overview 7 | 8 | ## Features 9 | 10 | Bramble supports: 11 | 12 | - Shared types across services 13 | - Namespaces 14 | - Field-level permissions 15 | - Plugins: 16 | - JWT, Open tracing, CORS, ... 17 | - Or add your own 18 | - Hot reloading of configuration 19 | 20 | It is also stateless and scales very easily. 21 | 22 | ## Future work/not currently supported 23 | 24 | There is currently no support for subscriptions. 25 | 26 | ## Contributing 27 | 28 | Contributions are always welcome! 29 | 30 | If you wish to contribute please open a pull request. Please make sure to: 31 | 32 | - include a brief description or link to the relevant issue 33 | - (if applicable) add tests for the behaviour you're adding/modifying 34 | - commit messages are descriptive 35 | 36 | Before making a significant change we recommend opening an issue to discuss 37 | the issue you're facing and the proposed solution. 38 | 39 | ### Building and testing 40 | 41 | Prerequisite: Go 1.23 42 | 43 | To build the `bramble` command: 44 | 45 | ```bash 46 | go build -o bramble ./cmd 47 | ./bramble -config config.json 48 | ``` 49 | 50 | To run the tests: 51 | 52 | ```bash 53 | go test ./... 54 | ``` 55 | 56 | ## Comparison with other projects 57 | 58 | Bramble provides a common-sense approach to GraphQL federation implemented in Golang. It assumes that subgraph fields are mutually exclusive, and that all boundary types join on a universal key. Compared with other projects: 59 | 60 | - [Apollo Federation](https://www.apollographql.com/) and [Golang port](https://github.com/jensneuse/graphql-go-tools): while quite popular, we felt the Apollo spec was more complex than necessary with its nuanced GraphQL SDL and specialized `_entities` query, and thus not the right fit for us. 61 | 62 | - [GraphQL Tools Stitching](https://www.graphql-tools.com/docs/schema-stitching/stitch-combining-schemas): while Stitching is similar in design to Bramble with self-contained subgraphs joined by basic queries, it offers more features than necessary at the cost of some performance overhead. It is also written in JavaScript where as we favour Golang. 63 | 64 | - [Nautilus](https://github.com/nautilus/gateway): provided a lot of inspiration for Bramble, and has been improved upon with bug fixes and additional features (fine-grained permissions, namespaces, better plugins, configuration hot-reloading). Bramble is a recommended successor. 65 | 66 | Bramble is a central piece of software for [Movio](https://movio.co) products and thus is actively maintained and developed. 67 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/movio/bramble 2 | 3 | go 1.23.3 4 | 5 | require ( 6 | github.com/99designs/gqlgen v0.17.73 7 | github.com/felixge/httpsnoop v1.0.4 8 | github.com/fsnotify/fsnotify v1.5.1 9 | github.com/golang-jwt/jwt/v4 v4.5.2 10 | github.com/golang/protobuf v1.5.4 // indirect 11 | github.com/gorilla/websocket v1.5.0 // indirect 12 | github.com/graph-gophers/graphql-go v1.5.0 13 | github.com/prometheus/client_golang v1.11.1 14 | github.com/prometheus/common v0.31.1 // indirect 15 | github.com/prometheus/procfs v0.7.3 // indirect 16 | github.com/rs/cors v1.7.0 17 | github.com/stretchr/testify v1.10.0 18 | github.com/vektah/gqlparser/v2 v2.5.27 19 | golang.org/x/crypto v0.38.0 // indirect 20 | golang.org/x/sync v0.14.0 21 | golang.org/x/sys v0.33.0 // indirect 22 | google.golang.org/protobuf v1.36.6 // indirect 23 | ) 24 | 25 | require ( 26 | github.com/agnivade/levenshtein v1.2.1 // indirect 27 | github.com/beorn7/perks v1.0.1 // indirect 28 | github.com/cespare/xxhash/v2 v2.2.0 // indirect 29 | github.com/davecgh/go-spew v1.1.1 // indirect 30 | github.com/gofrs/uuid v4.2.0+incompatible 31 | github.com/matttproud/golang_protobuf_extensions v1.0.1 // indirect 32 | github.com/pmezard/go-difflib v1.0.0 // indirect 33 | github.com/prometheus/client_model v0.2.0 // indirect 34 | gopkg.in/yaml.v3 v3.0.1 // indirect 35 | ) 36 | 37 | require ( 38 | github.com/google/uuid v1.6.0 // indirect 39 | github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect 40 | github.com/sosodev/duration v1.3.1 // indirect 41 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.52.0 42 | go.opentelemetry.io/otel v1.27.0 43 | go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.27.0 44 | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.27.0 45 | go.opentelemetry.io/otel/metric v1.27.0 // indirect 46 | go.opentelemetry.io/otel/sdk v1.27.0 47 | go.opentelemetry.io/otel/sdk/metric v1.27.0 48 | go.opentelemetry.io/otel/trace v1.27.0 49 | ) 50 | 51 | require github.com/go-jose/go-jose/v4 v4.0.5 52 | 53 | require ( 54 | github.com/cenkalti/backoff/v4 v4.3.0 // indirect 55 | github.com/go-logr/logr v1.4.1 // indirect 56 | github.com/go-logr/stdr v1.2.2 // indirect 57 | github.com/go-viper/mapstructure/v2 v2.4.0 // indirect 58 | github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0 // indirect 59 | go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.27.0 // indirect 60 | go.opentelemetry.io/proto/otlp v1.2.0 // indirect 61 | golang.org/x/net v0.40.0 // indirect 62 | golang.org/x/text v0.25.0 // indirect 63 | google.golang.org/genproto/googleapis/api v0.0.0-20240520151616-dc85e6b867a5 // indirect 64 | google.golang.org/genproto/googleapis/rpc v0.0.0-20240515191416-fc5f0ca64291 // indirect 65 | google.golang.org/grpc v1.64.1 // indirect 66 | ) 67 | -------------------------------------------------------------------------------- /docs/write-plugin.md: -------------------------------------------------------------------------------- 1 | # Writing a plugin 2 | 3 | ## Plugin interface 4 | 5 | Plugins must implement the [`Plugin`](https://pkg.go.dev/github.com/movio/bramble/bramble#Plugin) interface. Optionaly they can derive from 6 | the [`BasePlugin`](https://pkg.go.dev/github.com/movio/bramble/bramble#BasePlugin) implementation as to avoid redefining all methods. 7 | 8 | ```go 9 | type MyPlugin struct { 10 | bramble.Plugin 11 | } 12 | 13 | func (p *MyPlugin) ID() string { 14 | return "my-plugin" 15 | } 16 | ``` 17 | 18 | ## Plugin registration 19 | 20 | Plugins must register themselves using `RegisterPlugin`. 21 | Once registered they can be enabled and configured through Bramble's configuration. 22 | 23 | ```go 24 | func init() { 25 | bramble.RegisterPlugin(&MyPlugin{}) 26 | } 27 | ``` 28 | 29 | ?> The `init` function can be defined multiple times in the same package. 30 | 31 | ## Compiling Bramble with custom Plugins 32 | 33 | To build Bramble with custom plugins simply create your own `main.go` with an anonymous import of your plugins' package. 34 | 35 | ```go 36 | package main 37 | 38 | import ( 39 | "github.com/movio/bramble" 40 | _ "github.com/movio/bramble/plugins" 41 | _ "github.com/your/custom/package" 42 | ) 43 | 44 | func main() { 45 | bramble.Main() 46 | } 47 | ``` 48 | 49 | ## How to 50 | 51 | ### Configure the plugin 52 | 53 | ```go 54 | type MyPluginConfig struct { 55 | // ... 56 | } 57 | 58 | type MyPlugin struct { 59 | config MyPluginConfig 60 | } 61 | 62 | func (p *MyPlugin) Configure(cfg *bramble.Config, data json.RawMessage) error { 63 | // data contains the raw "config" JSON for the plugin 64 | return json.Unmarshal(data, &p.config) 65 | } 66 | ``` 67 | 68 | ### Initialize the plugin 69 | 70 | `Init` gives an opportunity to the plugin to access and store a pointer to 71 | the `ExecutableSchema` (contains information about the schema, services...). 72 | 73 | ```go 74 | func (p *MyPlugin) Init(s *bramble.ExecutableSchema) { 75 | // ... 76 | } 77 | ``` 78 | 79 | ### Register a new route 80 | 81 | ```go 82 | func (p *MyPlugin) SetupPublicMux(mux *http.ServeMux) { 83 | mux.HandleFunc("/my-new-public-route", newRouteHandler) 84 | } 85 | 86 | func (p *MyPlugin) SetupPrivateMux(mux *http.ServeMux) { 87 | mux.HandleFunc("/my-new-private-route", newRouteHandler) 88 | } 89 | 90 | func newRouteHandler(w http.ResponseWriter, r *http.Request) { 91 | // ... 92 | } 93 | ``` 94 | 95 | ### Expose and federate a GraphQL endpoint 96 | 97 | Plugins can also act as federated services. For this use `GraphqlQueryPath` 98 | to return the private route used by your graphql endpoint. 99 | 100 | ```go 101 | func (i *InternalsServicePlugin) GraphqlQueryPath() (bool, string) { 102 | return true, "my-graphql-endpoint" 103 | } 104 | 105 | func (i *InternalsServicePlugin) SetupPrivateMux(mux *http.ServeMux) { 106 | mux.Handle("/my-graphql-endpoint", myGraphqlHandler) 107 | } 108 | ``` 109 | 110 | ### Apply a middleware 111 | 112 | ```go 113 | func (p *MyPlugin) ApplyMiddlewarePublicMux(h http.Handler) http.Handler { 114 | // add a timeout to queries 115 | return http.TimeoutHandler(h, 1 * time.Second, "query timeout") 116 | } 117 | ``` 118 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package bramble 2 | 3 | import ( 4 | "context" 5 | "flag" 6 | "fmt" 7 | log "log/slog" 8 | "net/http" 9 | "os" 10 | "os/signal" 11 | "sync" 12 | "time" 13 | ) 14 | 15 | // Main runs the gateway. This function is exported so that it can be reused 16 | // when building Bramble with custom plugins. 17 | func Main() { 18 | ctx := context.Background() 19 | 20 | var configFiles arrayFlags 21 | level := new(log.LevelVar) 22 | flag.Var(&configFiles, "config", "Config file (can appear multiple times)") 23 | flag.Var(&configFiles, "conf", "deprecated, use -config instead") 24 | flag.TextVar(level, "loglevel", level, "log level: debug, info, warn, error") 25 | flag.Parse() 26 | 27 | logger := log.New(log.NewJSONHandler(os.Stderr, &log.HandlerOptions{Level: level})) 28 | log.SetDefault(logger) 29 | 30 | cfg, err := GetConfig(configFiles) 31 | if err != nil { 32 | log.With("error", err).Error("failed to load config") 33 | os.Exit(1) 34 | } 35 | go cfg.Watch() 36 | 37 | shutdown, err := InitTelemetry(ctx, cfg.Telemetry) 38 | if err != nil { 39 | log.With("error", err).Error("failed initializing telemetry") 40 | } 41 | 42 | defer func() { 43 | log.Info("flushing and shutting down telemetry") 44 | if err := shutdown(context.Background()); err != nil { 45 | log.With("error", err).Error("shutting down telemetry") 46 | } 47 | }() 48 | 49 | err = cfg.Init() 50 | if err != nil { 51 | log.With("error", err).Error("failed to configure") 52 | os.Exit(1) 53 | } 54 | 55 | log.With("config", cfg).Debug("configuration") 56 | 57 | gtw := NewGateway(cfg.executableSchema, cfg.plugins) 58 | RegisterMetrics() 59 | 60 | go gtw.UpdateSchemas(cfg.PollIntervalDuration) 61 | 62 | ctx, cancel := signal.NotifyContext(ctx, os.Interrupt) 63 | defer cancel() 64 | 65 | var wg sync.WaitGroup 66 | wg.Add(3) 67 | 68 | go runHandler(ctx, &wg, "metrics", cfg.MetricAddress(), cfg.DefaultTimeouts, NewMetricsHandler()) 69 | go runHandler(ctx, &wg, "private", cfg.PrivateAddress(), cfg.PrivateTimeouts, gtw.PrivateRouter()) 70 | go runHandler(ctx, &wg, "public", cfg.GatewayAddress(), cfg.GatewayTimeouts, gtw.Router(cfg)) 71 | 72 | wg.Wait() 73 | } 74 | 75 | func runHandler(ctx context.Context, wg *sync.WaitGroup, name, addr string, timeouts TimeoutConfig, handler http.Handler) { 76 | srv := &http.Server{ 77 | Addr: addr, 78 | Handler: handler, 79 | ReadTimeout: timeouts.ReadTimeoutDuration, 80 | WriteTimeout: timeouts.WriteTimeoutDuration, 81 | IdleTimeout: timeouts.IdleTimeoutDuration, 82 | } 83 | 84 | go func() { 85 | log.With("addr", addr).Info(fmt.Sprintf("serving %s handler", name)) 86 | if err := srv.ListenAndServe(); err != http.ErrServerClosed { 87 | log.With("error", err).Error("server terminated unexpectedly") 88 | os.Exit(1) 89 | } 90 | }() 91 | 92 | <-ctx.Done() 93 | 94 | timeoutCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 95 | defer cancel() 96 | 97 | log.Info(fmt.Sprintf("shutting down %s handler", name)) 98 | err := srv.Shutdown(timeoutCtx) 99 | if err != nil { 100 | log.With("error", err).Error("failed shutting down server") 101 | } 102 | log.Info(fmt.Sprintf("shut down %s handler", name)) 103 | wg.Done() 104 | } 105 | -------------------------------------------------------------------------------- /plugins/admin_ui.go: -------------------------------------------------------------------------------- 1 | package plugins 2 | 3 | import ( 4 | "bytes" 5 | _ "embed" 6 | "errors" 7 | "html/template" 8 | log "log/slog" 9 | "net/http" 10 | "os" 11 | "sort" 12 | 13 | "github.com/vektah/gqlparser/v2" 14 | "github.com/vektah/gqlparser/v2/ast" 15 | "github.com/vektah/gqlparser/v2/formatter" 16 | 17 | "github.com/movio/bramble" 18 | ) 19 | 20 | func init() { 21 | bramble.RegisterPlugin(&AdminUIPlugin{}) 22 | } 23 | 24 | // AdminUIPlugin serves a minimal administration interface. 25 | type AdminUIPlugin struct { 26 | bramble.BasePlugin 27 | executableSchema *bramble.ExecutableSchema 28 | template *template.Template 29 | } 30 | 31 | func (p *AdminUIPlugin) ID() string { 32 | return "admin-ui" 33 | } 34 | 35 | func (p *AdminUIPlugin) Init(s *bramble.ExecutableSchema) { 36 | tmpl := template.New("admin") 37 | _, err := tmpl.Parse(htmlTemplate) 38 | if err != nil { 39 | log.With("error", err).Error("unable to load admin UI page template") 40 | os.Exit(1) 41 | } 42 | 43 | p.template = tmpl 44 | p.executableSchema = s 45 | } 46 | 47 | func (p *AdminUIPlugin) SetupPrivateMux(mux *http.ServeMux) { 48 | mux.HandleFunc("/admin", p.handler) 49 | } 50 | 51 | type services []service 52 | 53 | func (s services) Len() int { 54 | return len(s) 55 | } 56 | 57 | func (s services) Less(i, j int) bool { 58 | return s[i].Name < s[j].Name 59 | } 60 | 61 | func (s services) Swap(i, j int) { 62 | s[i], s[j] = s[j], s[i] 63 | } 64 | 65 | type service struct { 66 | Name string 67 | Version string 68 | ServiceURL string 69 | Schema string 70 | Status string 71 | } 72 | 73 | type templateVariables struct { 74 | TestedSchema string 75 | TestSchemaResult string 76 | TestSchemaError string 77 | Services services 78 | } 79 | 80 | func (p *AdminUIPlugin) handler(w http.ResponseWriter, r *http.Request) { 81 | var vars templateVariables 82 | 83 | if testSchema := r.FormValue("schema"); testSchema != "" { 84 | vars.TestedSchema = testSchema 85 | resultSchema, err := p.testSchema(testSchema) 86 | vars.TestSchemaResult = resultSchema 87 | if err != nil { 88 | vars.TestSchemaError = err.Error() 89 | } 90 | } 91 | 92 | for _, s := range p.executableSchema.Services { 93 | vars.Services = append(vars.Services, service{ 94 | Name: s.Name, 95 | Version: s.Version, 96 | ServiceURL: s.ServiceURL, 97 | Schema: s.SchemaSource, 98 | Status: s.Status, 99 | }) 100 | } 101 | 102 | sort.Sort(vars.Services) 103 | 104 | _ = p.template.Execute(w, vars) 105 | } 106 | 107 | func (p *AdminUIPlugin) testSchema(schemaStr string) (string, error) { 108 | schema, gqlErr := gqlparser.LoadSchema(&ast.Source{Input: schemaStr}) 109 | if gqlErr != nil { 110 | return "", errors.New(gqlErr.Error()) 111 | } 112 | 113 | if err := bramble.ValidateSchema(schema); err != nil { 114 | return "", err 115 | } 116 | 117 | schemas := []*ast.Schema{schema} 118 | for _, service := range p.executableSchema.Services { 119 | schemas = append(schemas, service.Schema) 120 | } 121 | 122 | result, err := bramble.MergeSchemas(schemas...) 123 | if err != nil { 124 | return "", err 125 | } 126 | 127 | var buf bytes.Buffer 128 | f := formatter.NewFormatter(&buf) 129 | f.FormatSchema(result) 130 | 131 | return buf.String(), nil 132 | } 133 | 134 | //go:embed admin_ui.html.template 135 | var htmlTemplate string 136 | -------------------------------------------------------------------------------- /testsrv/gadget_test_server.go: -------------------------------------------------------------------------------- 1 | package testsrv 2 | 3 | import ( 4 | "errors" 5 | "net/http/httptest" 6 | 7 | "github.com/graph-gophers/graphql-go" 8 | "github.com/graph-gophers/graphql-go/relay" 9 | ) 10 | 11 | type gizmoWithGadgetResolver struct { 12 | IDField string 13 | Gadget *gadgetResolver 14 | } 15 | 16 | func (u gizmoWithGadgetResolver) ID() graphql.ID { 17 | return graphql.ID(u.IDField) 18 | } 19 | 20 | type gadgetServiceResolver struct { 21 | serviceField service 22 | } 23 | 24 | func (g *gadgetServiceResolver) Service() service { 25 | return g.serviceField 26 | } 27 | 28 | func (g *gadgetServiceResolver) BoundaryGizmo(args struct{ ID string }) (*gizmoWithGadgetResolver, error) { 29 | gadget, ok := gadgetMap[args.ID] 30 | if !ok { 31 | return nil, errors.New("gadget not found") 32 | } 33 | return &gizmoWithGadgetResolver{ 34 | IDField: args.ID, 35 | Gadget: &gadgetResolver{gadget}, 36 | }, nil 37 | } 38 | 39 | type gadget interface { 40 | ID() graphql.ID 41 | Name() string 42 | } 43 | 44 | type gadgetResolver struct { 45 | gadget 46 | } 47 | 48 | var gadgetMap = map[string]gadget{ 49 | "GIZMO1": &jetpack{ 50 | IDField: "JETPACK1", 51 | NameField: "Jetpack #1", 52 | RangeField: "500km", 53 | }, 54 | "GIZMO2": &invisibleCar{ 55 | IDField: "AM1", 56 | NameField: "Vanquish", 57 | CloakedField: true, 58 | }, 59 | } 60 | 61 | type jetpack struct { 62 | IDField string 63 | NameField string 64 | RangeField string 65 | } 66 | 67 | func (r *gadgetResolver) ToJetpack() (*jetpack, bool) { 68 | jetpack, ok := r.gadget.(*jetpack) 69 | return jetpack, ok 70 | } 71 | 72 | func (j jetpack) ID() graphql.ID { 73 | return graphql.ID(j.IDField) 74 | } 75 | 76 | func (j jetpack) Name() string { 77 | return j.NameField 78 | } 79 | 80 | func (j jetpack) Range() string { 81 | return j.RangeField 82 | } 83 | 84 | type invisibleCar struct { 85 | IDField string 86 | NameField string 87 | CloakedField bool 88 | } 89 | 90 | func (r *gadgetResolver) ToInvisibleCar() (*invisibleCar, bool) { 91 | invisableCar, ok := r.gadget.(*invisibleCar) 92 | return invisableCar, ok 93 | } 94 | 95 | func (j invisibleCar) ID() graphql.ID { 96 | return graphql.ID(j.IDField) 97 | } 98 | 99 | func (j invisibleCar) Name() string { 100 | return j.NameField 101 | } 102 | 103 | func (j invisibleCar) Cloaked() bool { 104 | return j.CloakedField 105 | } 106 | 107 | func NewGadgetService() *httptest.Server { 108 | s := ` 109 | directive @boundary on OBJECT | FIELD_DEFINITION 110 | 111 | type Query { 112 | service: Service! 113 | boundaryGizmo(id: ID!): Gizmo @boundary 114 | } 115 | 116 | type Gizmo @boundary { 117 | id: ID! 118 | gadget: Gadget 119 | } 120 | 121 | interface Gadget { 122 | id: ID! 123 | name: String! 124 | } 125 | 126 | type Jetpack implements Gadget { 127 | id: ID! 128 | name: String! 129 | range: String! 130 | } 131 | 132 | type InvisibleCar implements Gadget { 133 | id: ID! 134 | name: String! 135 | cloaked: Boolean! 136 | } 137 | 138 | type Service { 139 | name: String! 140 | version: String! 141 | schema: String! 142 | }` 143 | 144 | schema := graphql.MustParseSchema(s, &gadgetServiceResolver{ 145 | serviceField: service{ 146 | Name: "gadget-service", 147 | Version: "v0.0.1", 148 | Schema: s, 149 | }, 150 | }, graphql.UseFieldResolvers()) 151 | 152 | return httptest.NewServer(&relay.Handler{Schema: schema}) 153 | } 154 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Bramble 2 | 3 | [![Go Reference](https://pkg.go.dev/badge/github.com/movio/bramble.svg)](https://pkg.go.dev/github.com/movio/bramble) 4 | [![Go Report Card](https://goreportcard.com/badge/github.com/movio/bramble)](https://goreportcard.com/report/github.com/movio/bramble) 5 | [![codecov](https://codecov.io/gh/movio/bramble/branch/main/graph/badge.svg)](https://codecov.io/gh/movio/bramble) 6 | 7 | [**Full documentation**](https://movio.github.io/bramble) 8 | 9 | Bramble is a production-ready GraphQL federation gateway. 10 | It is built to be a simple, reliable and scalable way to aggregate GraphQL services together. 11 | 12 | overview 13 | 14 | ## Features 15 | 16 | Bramble supports: 17 | 18 | - Shared types across services 19 | - Namespaces 20 | - Field-level permissions 21 | - Plugins: 22 | - JWT, CORS, ... 23 | - Or add your own 24 | - Hot reloading of configuration 25 | 26 | It is also stateless and scales very easily. 27 | 28 | ## Future work/not currently supported 29 | 30 | There is currently no support for: 31 | 32 | - Subscriptions 33 | - Shared unions, interfaces, scalars, enums or inputs across services 34 | 35 | Check FAQ for details: https://movio.github.io/bramble/#/federation?id=restriction-on-subscription 36 | 37 | ## Contributing 38 | 39 | Contributions are always welcome! 40 | 41 | If you wish to contribute please open a pull request. Please make sure to: 42 | 43 | - include a brief description or link to the relevant issue 44 | - (if applicable) add tests for the behaviour you're adding/modifying 45 | - commit messages are descriptive 46 | 47 | Before making a significant change we recommend opening an issue to discuss 48 | the issue you're facing and the proposed solution. 49 | 50 | ### Building and testing 51 | 52 | Prerequisite: Go 1.23 or newer 53 | 54 | To build the `bramble` command: 55 | 56 | ```bash 57 | go build -o bramble ./cmd/bramble 58 | ./bramble -config config.json 59 | ``` 60 | 61 | To run the tests: 62 | 63 | ```bash 64 | go test ./... 65 | ``` 66 | 67 | # Running locally 68 | 69 | There is a [docker-compose](./docker-compose.yaml) file that will run bramble and three [example](./examples) services. 70 | 71 | ``` 72 | docker-compose up 73 | ``` 74 | 75 | The gateway will then be hosted on `http://localhost:8082/query`, be sure to point a GraphQL client to this address. 76 | 77 | ```graphql 78 | { 79 | randomFoo { 80 | nodejs 81 | graphGophers 82 | gqlgen 83 | } 84 | } 85 | ``` 86 | 87 | ## Comparison with other projects 88 | 89 | - [Apollo Server](https://www.apollographql.com/) 90 | 91 | While Apollo Server is a popular tool we felt is was not the right tool for us as: 92 | 93 | - the federation specification is more complex than necessary 94 | - it is written in NodeJS where we favour Go 95 | 96 | - [Nautilus](https://github.com/nautilus/gateway) 97 | 98 | Nautilus provided a lot of inspiration for Bramble. 99 | 100 | Although the approach to federation was initially similar, Bramble now uses 101 | a different approach and supports for a few more things: 102 | fine-grained permissions, namespaces, easy plugin configuration, 103 | configuration hot-reloading... 104 | 105 | Bramble is also a central piece of software for [Movio](https://movio.co) 106 | products and thus is actively maintained and developed. 107 | -------------------------------------------------------------------------------- /instrumentation_test.go: -------------------------------------------------------------------------------- 1 | package bramble 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "io" 7 | log "log/slog" 8 | "sync" 9 | "testing" 10 | "time" 11 | 12 | "github.com/stretchr/testify/assert" 13 | ) 14 | 15 | // can only run one test at a time that takes over the log output 16 | var logLock = sync.Mutex{} 17 | 18 | func collectLogEvent(t *testing.T, o *log.HandlerOptions, f func()) map[string]interface{} { 19 | t.Helper() 20 | r, w := io.Pipe() 21 | defer r.Close() 22 | prevlogger := log.Default() 23 | logLock.Lock() 24 | defer logLock.Unlock() 25 | log.SetDefault(log.New(log.NewJSONHandler(w, o))) 26 | t.Cleanup(func() { 27 | logLock.Lock() 28 | defer logLock.Unlock() 29 | log.SetDefault(prevlogger) 30 | }) 31 | 32 | go func() { 33 | defer w.Close() 34 | f() 35 | }() 36 | 37 | var obj map[string]interface{} 38 | err := json.NewDecoder(r).Decode(&obj) 39 | assert.NoError(t, err) 40 | 41 | return obj 42 | } 43 | 44 | func collectEventFromContext(ctx context.Context, t *testing.T, o *log.HandlerOptions, f func(*event)) map[string]interface{} { 45 | t.Helper() 46 | return collectLogEvent(t, o, func() { 47 | e := getEvent(ctx) 48 | f(e) 49 | if e != nil { 50 | e.finish() 51 | } 52 | }) 53 | } 54 | 55 | func testEventName(EventFields) string { 56 | return "test" 57 | } 58 | 59 | func TestDropsField(t *testing.T) { 60 | AddField(context.TODO(), "val", "test") 61 | assert.True(t, true) 62 | } 63 | 64 | func TestEventLogOnFinish(t *testing.T) { 65 | ctx, _ := startEvent(context.TODO(), testEventName) 66 | output := collectEventFromContext(ctx, t, nil, func(*event) { 67 | AddField(ctx, "val", "test") 68 | }) 69 | 70 | assert.Equal(t, "test", output["val"]) 71 | } 72 | 73 | func TestAddMultipleToEventOnContext(t *testing.T) { 74 | ctx, _ := startEvent(context.TODO(), testEventName) 75 | output := collectEventFromContext(ctx, t, nil, func(*event) { 76 | AddFields(ctx, EventFields{ 77 | "gizmo": "foo", 78 | "gimmick": "bar", 79 | }) 80 | }) 81 | 82 | assert.Equal(t, "foo", output["gizmo"]) 83 | assert.Equal(t, "bar", output["gimmick"]) 84 | } 85 | 86 | func TestEventMeasurement(t *testing.T) { 87 | start := time.Now() 88 | ctx, _ := startEvent(context.TODO(), testEventName) 89 | output := collectEventFromContext(ctx, t, nil, func(*event) { 90 | time.Sleep(time.Microsecond) 91 | }) 92 | 93 | if ts, ok := output["time"].(string); ok { 94 | timestamp, err := time.Parse(time.RFC3339Nano, ts) 95 | assert.NoError(t, err) 96 | assert.WithinDuration(t, start, timestamp, time.Second) 97 | } else { 98 | assert.Fail(t, "missing timestamp") 99 | } 100 | if dur, ok := output["duration"].(string); ok { 101 | duration, err := time.ParseDuration(dur) 102 | assert.NoError(t, err) 103 | assert.True(t, duration > 0) 104 | } else { 105 | assert.Fail(t, "missing duration") 106 | } 107 | } 108 | 109 | func TestDebugDisabled(t *testing.T) { 110 | ctx, _ := startEvent(context.TODO(), testEventName) 111 | 112 | o := &log.HandlerOptions{ 113 | Level: log.LevelInfo, 114 | } 115 | 116 | output := collectEventFromContext(ctx, t, o, func(e *event) { 117 | if e.debugEnabled() { 118 | AddField(ctx, "val", "test") 119 | } 120 | }) 121 | 122 | assert.Empty(t, output["val"]) 123 | } 124 | 125 | func TestDebugEnabled(t *testing.T) { 126 | ctx, _ := startEvent(context.TODO(), testEventName) 127 | 128 | o := &log.HandlerOptions{ 129 | Level: log.LevelDebug, 130 | } 131 | 132 | output := collectEventFromContext(ctx, t, o, func(e *event) { 133 | if e.debugEnabled() { 134 | AddField(ctx, "val", "test") 135 | } 136 | }) 137 | 138 | assert.Equal(t, "test", output["val"]) 139 | } 140 | -------------------------------------------------------------------------------- /docs/configuration.md: -------------------------------------------------------------------------------- 1 | # Configuration 2 | 3 | Bramble can be configured by passing one or more JSON config file with the `-config` parameter. 4 | 5 | Config files are also hot-reloaded on change (see below for list of supported options). 6 | 7 | Sample configuration: 8 | 9 | ```json 10 | { 11 | "services": ["http://service1/query", "http://service2/query"], 12 | "gateway-port": 8082, 13 | "private-port": 8083, 14 | "metrics-port": 9009, 15 | "log-level": "info", 16 | "poll-interval": "5s", 17 | "max-requests-per-query": 50, 18 | "max-client-response-size": 1048576, 19 | "id-field-name": "id", 20 | "telemetry": { 21 | "enabled": true, 22 | "insecure": false, 23 | "endpoint": "http://localhost:4317", 24 | "serviceName": "bramble" 25 | }, 26 | "plugins": [ 27 | { 28 | "name": "admin-ui" 29 | }, 30 | { 31 | "name": "my-plugin", 32 | "config": { 33 | ... 34 | } 35 | } 36 | ], 37 | "extensions": { 38 | ... 39 | } 40 | } 41 | ``` 42 | 43 | - `services`: URLs of services to federate. 44 | 45 | - **Required** 46 | - Supports hot-reload: Yes 47 | - Configurable also by `BRAMBLE_SERVICE_LIST` environment variable set to a space separated list of urls which will be appended to the list 48 | 49 | - `gateway-port`: public port for the gateway, this is where the query endpoint 50 | is exposed. Plugins can expose additional endpoints on this port. 51 | 52 | - Default: 8082 53 | - Supports hot-reload: No 54 | 55 | - `private-port`: A port for plugins to expose private endpoints. Not used by default. 56 | 57 | - Default: 8083 58 | - Supports hot-reload: No 59 | 60 | - `metrics-port`: Port used to expose Prometheus metrics. 61 | 62 | - Default: 9009 63 | - Supports hot-reload: No 64 | 65 | - `log-level`: Log level, one of `debug`|`info`|`error`|`fatal`. 66 | 67 | - Default: `debug` 68 | - Supports hot-reload: Yes 69 | 70 | - `poll-interval`: Interval at which federated services are polled (`service` query is called). 71 | 72 | - Default: `5s` 73 | - Supports hot-reload: No 74 | 75 | - `max-requests-per-query`: Maximum number of requests to federated services 76 | a single query to Bramble can generate. For example, a query requesting 77 | fields from two different services might generate two or more requests to 78 | federated services. 79 | 80 | - Default: 50 81 | - Supports hot-reload: No 82 | 83 | - `max-service-response-size`: The max response size that Bramble can receive from federated services 84 | 85 | - Default: 1MB 86 | - Supports hot-reload: No 87 | 88 | - `id-field-name`: Optional customisation of the field name used to cross-reference boundary types. 89 | 90 | - Default: `id` 91 | - Supports hot-reload: No 92 | 93 | - `telemetry`: OpenTelemetry configuration. 94 | - `enabled`: Enable OpenTelemetry. 95 | - Default: `false` 96 | - Supports hot-reload: No 97 | - `insecure`: Whether to use insecure connection to OpenTelemetry collector. 98 | - Default: `false` 99 | - Supports hot-reload: No 100 | - `endpoint`: OpenTelemetry collector endpoint. 101 | - Default: If no endpoint is specified, telemetry is disabled. Bramble will check for `BRAMBLE_OTEL_ENDPOINT` environment variable and use it if set. 102 | - Supports hot-reload: No 103 | - `serviceName`: Service name to use for OpenTelemetry. 104 | - Default: `bramble` 105 | - Supports hot-reload: No 106 | 107 | 108 | - `plugins`: Optional list of plugins to enable. See [plugins](plugins.md) for plugins-specific config. 109 | 110 | - Supports hot-reload: Partial. `Configure` method of previously enabled plugins will get called with new configuration. 111 | 112 | - `extensions`: Non-standard configuration, can be used to share configuration across plugins. 113 | -------------------------------------------------------------------------------- /metrics.go: -------------------------------------------------------------------------------- 1 | package bramble 2 | 3 | import ( 4 | "net/http" 5 | "net/http/pprof" 6 | 7 | "github.com/prometheus/client_golang/prometheus" 8 | "github.com/prometheus/client_golang/prometheus/promhttp" 9 | ) 10 | 11 | var ( 12 | // promInvalidSchema is a gauge representing the current status of remote services schemas 13 | promInvalidSchema = prometheus.NewGauge(prometheus.GaugeOpts{ 14 | Name: "invalid_schema", 15 | Help: "A gauge representing the current status of remote services schemas", 16 | }) 17 | 18 | promServiceUpdateErrorCounter = prometheus.NewCounterVec( 19 | prometheus.CounterOpts{ 20 | Name: "service_update_error_total", 21 | Help: "A counter indicating how many times services have failed to update", 22 | }, 23 | []string{ 24 | "service", 25 | }, 26 | ) 27 | 28 | promServiceTimeoutErrorCounter = prometheus.NewCounterVec( 29 | prometheus.CounterOpts{ 30 | Name: "service_timeout_error_total", 31 | Help: "A counter indicating how many times services have timed out", 32 | }, 33 | []string{ 34 | "service", 35 | }, 36 | ) 37 | 38 | promServiceUpdateErrorGauge = prometheus.NewGaugeVec( 39 | prometheus.GaugeOpts{ 40 | Name: "service_update_error", 41 | Help: "A gauge indicating what services are failing to update", 42 | }, 43 | []string{ 44 | "service", 45 | }, 46 | ) 47 | 48 | // promHTTPInFlightGauge is a gauge of requests currently being served by the wrapped handler 49 | promHTTPInFlightGauge = prometheus.NewGauge(prometheus.GaugeOpts{ 50 | Name: "http_in_flight_requests", 51 | Help: "A gauge of requests currently being served", 52 | }) 53 | 54 | // promHTTPRequestCounter is a counter for requests to the wrapped handler 55 | promHTTPRequestCounter = prometheus.NewCounterVec( 56 | prometheus.CounterOpts{ 57 | Name: "http_api_requests_total", 58 | Help: "A counter for served requests", 59 | }, 60 | []string{"code"}, 61 | ) 62 | 63 | // promHTTPResponseDurations is a histogram of request latencies 64 | promHTTPResponseDurations = prometheus.NewHistogramVec( 65 | prometheus.HistogramOpts{ 66 | Name: "http_response_duration_seconds", 67 | Help: "A histogram of request latencies", 68 | Buckets: prometheus.DefBuckets, 69 | }, 70 | []string{}, 71 | ) 72 | 73 | // promHTTPRequestSizes is a histogram of request sizes for requests 74 | promHTTPRequestSizes = prometheus.NewHistogramVec( 75 | prometheus.HistogramOpts{ 76 | Name: "http_request_size_bytes", 77 | Help: "A histogram of request sizes for requests", 78 | Buckets: prometheus.ExponentialBuckets(128, 2, 10), 79 | }, 80 | []string{}, 81 | ) 82 | 83 | // promHTTPResponseSizes is a histogram of response sizes for responses. 84 | promHTTPResponseSizes = prometheus.NewHistogramVec( 85 | prometheus.HistogramOpts{ 86 | Name: "http_response_size_bytes", 87 | Help: "A histogram of response sizes for responses", 88 | Buckets: prometheus.ExponentialBuckets(1024, 2, 10), 89 | }, 90 | []string{}, 91 | ) 92 | ) 93 | 94 | // RegisterMetrics register the prometheus metrics. 95 | func RegisterMetrics() { 96 | prometheus.MustRegister(promInvalidSchema) 97 | prometheus.MustRegister(promServiceTimeoutErrorCounter) 98 | prometheus.MustRegister(promServiceUpdateErrorCounter) 99 | prometheus.MustRegister(promServiceUpdateErrorGauge) 100 | prometheus.MustRegister(promHTTPInFlightGauge) 101 | prometheus.MustRegister(promHTTPRequestCounter) 102 | prometheus.MustRegister(promHTTPResponseDurations) 103 | prometheus.MustRegister(promHTTPRequestSizes) 104 | prometheus.MustRegister(promHTTPResponseSizes) 105 | } 106 | 107 | // NewMetricsHandler returns a new Prometheus metrics handler. 108 | func NewMetricsHandler() http.Handler { 109 | mux := http.NewServeMux() 110 | mux.Handle("/metrics", promhttp.Handler()) 111 | mux.HandleFunc("/debug/pprof/", pprof.Index) 112 | 113 | return mux 114 | } 115 | -------------------------------------------------------------------------------- /server_test.go: -------------------------------------------------------------------------------- 1 | package bramble 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/movio/bramble/testsrv" 8 | "github.com/stretchr/testify/require" 9 | "github.com/vektah/gqlparser/v2" 10 | ) 11 | 12 | func TestFederatedQuery(t *testing.T) { 13 | gizmoService := testsrv.NewGizmoService() 14 | gadgetService := testsrv.NewGadgetService() 15 | 16 | executableSchema := NewExecutableSchema(nil, 10, nil, NewService(gizmoService.URL), NewService(gadgetService.URL)) 17 | 18 | require.NoError(t, executableSchema.UpdateSchema(context.TODO(), true)) 19 | 20 | query := gqlparser.MustLoadQuery(executableSchema.MergedSchema, `{ 21 | gizmo(id: "GIZMO1") { 22 | id 23 | name 24 | } 25 | }`) 26 | 27 | ctx := testContextWithoutVariables(query.Operations[0]) 28 | 29 | response := executableSchema.ExecuteQuery(ctx) 30 | expectedResponse := ` 31 | { 32 | "gizmo": { 33 | "id": "GIZMO1", 34 | "name": "Gizmo #1" 35 | } 36 | } 37 | ` 38 | jsonEqWithOrder(t, expectedResponse, string(response.Data)) 39 | gizmoService.Close() 40 | gadgetService.Close() 41 | } 42 | 43 | func TestFederatedQueryWithMultipleFragmentSpreads(t *testing.T) { 44 | gizmoService := testsrv.NewGizmoService() 45 | gadgetService := testsrv.NewGadgetService() 46 | 47 | executableSchema := NewExecutableSchema(nil, 10, nil, NewService(gizmoService.URL), NewService(gadgetService.URL)) 48 | 49 | require.NoError(t, executableSchema.UpdateSchema(context.TODO(), true)) 50 | 51 | t.Run("first fragment matches", func(t *testing.T) { 52 | query := gqlparser.MustLoadQuery(executableSchema.MergedSchema, `{ 53 | gizmo(id: "GIZMO1") { 54 | id 55 | name 56 | gadget { 57 | id 58 | name 59 | ... on Jetpack { 60 | range 61 | } 62 | ... on InvisibleCar { 63 | cloaked 64 | } 65 | } 66 | } 67 | }`) 68 | 69 | ctx := testContextWithoutVariables(query.Operations[0]) 70 | 71 | response := executableSchema.ExecuteQuery(ctx) 72 | expectedResponse := ` 73 | { 74 | "gizmo": { 75 | "id": "GIZMO1", 76 | "name": "Gizmo #1", 77 | "gadget": { 78 | "id": "JETPACK1", 79 | "name": "Jetpack #1", 80 | "range": "500km" 81 | } 82 | } 83 | }` 84 | 85 | jsonEqWithOrder(t, expectedResponse, string(response.Data)) 86 | }) 87 | 88 | t.Run("second fragment matches", func(t *testing.T) { 89 | query := gqlparser.MustLoadQuery(executableSchema.MergedSchema, `{ 90 | gizmo(id: "GIZMO2") { 91 | id 92 | name 93 | gadget { 94 | id 95 | name 96 | ... on Jetpack { 97 | range 98 | } 99 | ... on InvisibleCar { 100 | cloaked 101 | } 102 | } 103 | } 104 | }`) 105 | 106 | ctx := testContextWithoutVariables(query.Operations[0]) 107 | 108 | response := executableSchema.ExecuteQuery(ctx) 109 | expectedResponse := ` 110 | { 111 | "gizmo": { 112 | "id": "GIZMO2", 113 | "name": "Gizmo #2", 114 | "gadget": { 115 | "id": "AM1", 116 | "name": "Vanquish", 117 | "cloaked": true 118 | } 119 | } 120 | }` 121 | 122 | jsonEqWithOrder(t, expectedResponse, string(response.Data)) 123 | }) 124 | 125 | t.Run("no fragments match", func(t *testing.T) { 126 | query := gqlparser.MustLoadQuery(executableSchema.MergedSchema, `{ 127 | gizmo(id: "GIZMO2") { 128 | id 129 | name 130 | gadget { 131 | id 132 | name 133 | ... on Jetpack { 134 | range 135 | } 136 | } 137 | } 138 | }`) 139 | 140 | ctx := testContextWithoutVariables(query.Operations[0]) 141 | 142 | response := executableSchema.ExecuteQuery(ctx) 143 | expectedResponse := ` 144 | { 145 | "gizmo": { 146 | "id": "GIZMO2", 147 | "name": "Gizmo #2", 148 | "gadget": { 149 | "id": "AM1", 150 | "name": "Vanquish" 151 | } 152 | } 153 | }` 154 | 155 | jsonEqWithOrder(t, expectedResponse, string(response.Data)) 156 | }) 157 | 158 | gizmoService.Close() 159 | gadgetService.Close() 160 | } 161 | -------------------------------------------------------------------------------- /plugin.go: -------------------------------------------------------------------------------- 1 | package bramble 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | log "log/slog" 7 | "net/http" 8 | "os" 9 | 10 | "github.com/99designs/gqlgen/graphql" 11 | "github.com/99designs/gqlgen/graphql/handler" 12 | ) 13 | 14 | // Plugin is a Bramble plugin. Plugins can be used to extend base Bramble functionalities. 15 | type Plugin interface { 16 | // ID must return the plugin identifier (name). This is the id used to match 17 | // the plugin in the configuration. 18 | ID() string 19 | // Configure is called during initialization and every time the config is modified. 20 | // The pluginCfg argument is the raw json contained in the "config" key for that plugin. 21 | Configure(cfg *Config, pluginCfg json.RawMessage) error 22 | // Init is called once on initialization 23 | Init(schema *ExecutableSchema) 24 | SetupPublicMux(mux *http.ServeMux) 25 | SetupGatewayHandler(handler *handler.Server) 26 | SetupPrivateMux(mux *http.ServeMux) 27 | // Should return true and the query path if the plugin is a service that 28 | // should be federated by Bramble 29 | GraphqlQueryPath() (bool, string) 30 | ApplyMiddlewarePublicMux(http.Handler) http.Handler 31 | ApplyMiddlewarePrivateMux(http.Handler) http.Handler 32 | WrapGraphQLClientTransport(http.RoundTripper) http.RoundTripper 33 | 34 | InterceptRequest(ctx context.Context, operationName, rawQuery string, variables map[string]interface{}) 35 | InterceptResponse(ctx context.Context, operationName, rawQuery string, variables map[string]interface{}, response *graphql.Response) *graphql.Response 36 | } 37 | 38 | // BasePlugin is an empty plugin. It can be embedded by any plugin as a way to avoid 39 | // declaring unnecessary methods. 40 | type BasePlugin struct{} 41 | 42 | // Configure ... 43 | func (p *BasePlugin) Configure(*Config, json.RawMessage) error { 44 | return nil 45 | } 46 | 47 | // Init ... 48 | func (p *BasePlugin) Init(s *ExecutableSchema) {} 49 | 50 | func (p *BasePlugin) SetupGatewayHandler(handler *handler.Server) {} 51 | 52 | // SetupPublicMux ... 53 | func (p *BasePlugin) SetupPublicMux(mux *http.ServeMux) {} 54 | 55 | // SetupPrivateMux ... 56 | func (p *BasePlugin) SetupPrivateMux(mux *http.ServeMux) {} 57 | 58 | // GraphqlQueryPath ... 59 | func (p *BasePlugin) GraphqlQueryPath() (bool, string) { 60 | return false, "" 61 | } 62 | 63 | // InterceptRequest is called before bramble starts executing a request. 64 | // It can be used to inspect the unmarshalled GraphQL request bramble receives. 65 | func (p *BasePlugin) InterceptRequest(ctx context.Context, operationName, rawQuery string, variables map[string]interface{}) { 66 | } 67 | 68 | // InterceptResponse is called after bramble has finished executing a request. 69 | // It can be used to inspect and/or modify the response bramble will return. 70 | func (p *BasePlugin) InterceptResponse(ctx context.Context, operationName, rawQuery string, variables map[string]interface{}, response *graphql.Response) *graphql.Response { 71 | return response 72 | } 73 | 74 | // ApplyMiddlewarePublicMux ... 75 | func (p *BasePlugin) ApplyMiddlewarePublicMux(h http.Handler) http.Handler { 76 | return h 77 | } 78 | 79 | // ApplyMiddlewarePrivateMux ... 80 | func (p *BasePlugin) ApplyMiddlewarePrivateMux(h http.Handler) http.Handler { 81 | return h 82 | } 83 | 84 | // WrapGraphQLClientTransport wraps the http.RoundTripper used for GraphQL requests. 85 | func (p *BasePlugin) WrapGraphQLClientTransport(transport http.RoundTripper) http.RoundTripper { 86 | return transport 87 | } 88 | 89 | var registeredPlugins = map[string]Plugin{} 90 | 91 | // RegisterPlugin register a plugin so that it can be enabled via the configuration. 92 | func RegisterPlugin(p Plugin) { 93 | if _, found := registeredPlugins[p.ID()]; found { 94 | log.With("plugin", p.ID()).Error("plugin already registered") 95 | os.Exit(1) 96 | } 97 | registeredPlugins[p.ID()] = p 98 | } 99 | 100 | // RegisteredPlugins returned the list of registered plugins. 101 | func RegisteredPlugins() map[string]Plugin { 102 | return registeredPlugins 103 | } 104 | -------------------------------------------------------------------------------- /docs/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 8 | 24 | 41 | 42 | -------------------------------------------------------------------------------- /docs/bramble-header.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 11 | 26 | 27 | 33 | 36 | 38 | 40 | 46 | 47 | 49 | 50 | 51 | -------------------------------------------------------------------------------- /middleware.go: -------------------------------------------------------------------------------- 1 | package bramble 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "fmt" 8 | "io" 9 | "mime" 10 | "net" 11 | "net/http" 12 | "strings" 13 | 14 | "github.com/felixge/httpsnoop" 15 | "github.com/prometheus/client_golang/prometheus" 16 | ) 17 | 18 | type middleware func(http.Handler) http.Handler 19 | 20 | // DebugKey is used to request debug info from the context 21 | const DebugKey contextKey = "debug" 22 | 23 | const ( 24 | debugHeader = "X-Bramble-Debug" 25 | ) 26 | 27 | // DebugInfo contains the requested debug info for a query 28 | type DebugInfo struct { 29 | Variables bool 30 | Query bool 31 | Plan bool 32 | Timing bool 33 | TraceID bool 34 | } 35 | 36 | func debugMiddleware(h http.Handler) http.Handler { 37 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 38 | info := DebugInfo{} 39 | for _, field := range strings.Fields(r.Header.Get(debugHeader)) { 40 | switch field { 41 | case "all": 42 | info.Variables = true 43 | info.Plan = true 44 | info.Query = true 45 | info.Timing = true 46 | info.TraceID = true 47 | case "query": 48 | info.Query = true 49 | case "variables": 50 | info.Variables = true 51 | case "plan": 52 | info.Plan = true 53 | case "timing": 54 | info.Timing = true 55 | case "traceid": 56 | info.TraceID = true 57 | } 58 | } 59 | 60 | ctx := context.WithValue(r.Context(), DebugKey, info) 61 | h.ServeHTTP(w, r.WithContext(ctx)) 62 | }) 63 | } 64 | 65 | func monitoringMiddleware(h http.Handler) http.Handler { 66 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 67 | ctx, event := startEvent(r.Context(), nameMonitoringEvent) 68 | if !strings.HasPrefix(r.Header.Get("user-agent"), "Bramble") { 69 | defer event.finish() 70 | } 71 | 72 | if host := r.Header.Get("X-Forwarded-Host"); host != "" { 73 | event.addField("forwarded_host", host) 74 | } 75 | 76 | var buf bytes.Buffer 77 | _, err := io.Copy(&buf, r.Body) 78 | if err != nil { 79 | w.WriteHeader(http.StatusInternalServerError) 80 | return 81 | } 82 | r.Body = io.NopCloser(&buf) 83 | 84 | r = r.WithContext(ctx) 85 | 86 | if event.debugEnabled() { 87 | addRequestBody(event, r, buf) 88 | } 89 | 90 | m := httpsnoop.CaptureMetrics(h, w, r) 91 | 92 | event.addFields(EventFields{ 93 | "response.status": m.Code, 94 | "request.path": r.URL.Path, 95 | "response.size": m.Written, 96 | }) 97 | if ip, _, err := net.SplitHostPort(r.RemoteAddr); err == nil { 98 | event.addField("request.ip", ip) 99 | } 100 | 101 | promHTTPRequestCounter.With(prometheus.Labels{ 102 | "code": fmt.Sprintf("%dXX", m.Code/100), 103 | }).Inc() 104 | promHTTPRequestSizes.With(prometheus.Labels{}).Observe(float64(buf.Len())) 105 | promHTTPResponseSizes.With(prometheus.Labels{}).Observe(float64(m.Written)) 106 | promHTTPResponseDurations.With(prometheus.Labels{}).Observe(m.Duration.Seconds()) 107 | }) 108 | } 109 | 110 | func nameMonitoringEvent(fields EventFields) string { 111 | if t := fields["operation.type"]; t != nil { 112 | if n := fields["operation.name"]; n != "" { 113 | return fmt.Sprintf("%s:%s", t, n) 114 | } 115 | return fmt.Sprintf("%s", t) 116 | } 117 | return "request" 118 | } 119 | 120 | func addRequestBody(e *event, r *http.Request, buf bytes.Buffer) { 121 | contentType, _, _ := mime.ParseMediaType(r.Header.Get("Content-Type")) 122 | e.addField("request.content-type", contentType) 123 | 124 | if r.Method != http.MethodHead && r.Method != http.MethodGet { 125 | switch { 126 | case contentType == "application/json": 127 | var payload interface{} 128 | if err := json.Unmarshal(buf.Bytes(), &payload); err == nil { 129 | e.addField("request.body", &payload) 130 | } else { 131 | e.addField("request.body", buf.String()) 132 | e.addField("request.error", err) 133 | } 134 | case contentType == "multipart/form-data": 135 | e.addField("request.body", fmt.Sprintf("%d bytes", len(buf.Bytes()))) 136 | default: 137 | e.addField("request.body", buf.String()) 138 | } 139 | } else { 140 | e.addField("request.body", buf.String()) 141 | } 142 | } 143 | 144 | func applyMiddleware(h http.Handler, mws ...middleware) http.Handler { 145 | for _, mw := range mws { 146 | h = mw(h) 147 | } 148 | return h 149 | } 150 | -------------------------------------------------------------------------------- /examples/gqlgen-multipart-file-upload-service/go.sum: -------------------------------------------------------------------------------- 1 | github.com/99designs/gqlgen v0.17.44 h1:OS2wLk/67Y+vXM75XHbwRnNYJcbuJd4OBL76RX3NQQA= 2 | github.com/99designs/gqlgen v0.17.44/go.mod h1:UTCu3xpK2mLI5qcMNw+HKDiEL77it/1XtAjisC4sLwM= 3 | github.com/agnivade/levenshtein v1.1.1 h1:QY8M92nrzkmr798gCo3kmMyqXFzdQVpxLlGPRBij0P8= 4 | github.com/agnivade/levenshtein v1.1.1/go.mod h1:veldBMzWxcCG2ZvUTKD2kJNRdCk5hVbJomOvKkmgYbo= 5 | github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ= 6 | github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8= 7 | github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0 h1:jfIu9sQUG6Ig+0+Ap1h4unLjW6YQJpKZVmUzxsD4E/Q= 8 | github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE= 9 | github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w= 10 | github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= 11 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 12 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 13 | github.com/dgryski/trifles v0.0.0-20200323201526-dd97f9abfb48 h1:fRzb/w+pyskVMQ+UbP35JkH8yB7MYb4q/qhBarqZE6g= 14 | github.com/dgryski/trifles v0.0.0-20200323201526-dd97f9abfb48/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA= 15 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 16 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 17 | github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= 18 | github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 19 | github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= 20 | github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= 21 | github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= 22 | github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= 23 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 24 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 25 | github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= 26 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 27 | github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= 28 | github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= 29 | github.com/sosodev/duration v1.2.0 h1:pqK/FLSjsAADWY74SyWDCjOcd5l7H8GSnnOGEB9A1Us= 30 | github.com/sosodev/duration v1.2.0/go.mod h1:RQIBBX0+fMLc/D9+Jb/fwvVmo0eZvDDEERAikUR6SDg= 31 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= 32 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 33 | github.com/urfave/cli/v2 v2.27.1 h1:8xSQ6szndafKVRmfyeUMxkNUJQMjL1F2zmsZ+qHpfho= 34 | github.com/urfave/cli/v2 v2.27.1/go.mod h1:8qnjx1vcq5s2/wpsqoZFndg2CE5tNFyrTvS6SinrnYQ= 35 | github.com/vektah/gqlparser/v2 v2.5.15 h1:fYdnU8roQniJziV5TDiFPm/Ff7pE8xbVSOJqbsdl88A= 36 | github.com/vektah/gqlparser/v2 v2.5.15/go.mod h1:WQQjFc+I1YIzoPvZBhUQX7waZgg3pMLi0r8KymvAE2w= 37 | github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU= 38 | github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8= 39 | golang.org/x/mod v0.14.0 h1:dGoOF9QVLYng8IHTm7BAyWqCqSheQ5pYWGhzW00YJr0= 40 | golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= 41 | golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ= 42 | golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 43 | golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= 44 | golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 45 | golang.org/x/tools v0.17.0 h1:FvmRgNOcs3kOa+T20R1uhfP9F6HgG2mfxDv1vrx1Htc= 46 | golang.org/x/tools v0.17.0/go.mod h1:xsh6VxdV005rRVaS6SSAf9oiAqljS7UZUacMZ8Bnsps= 47 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 48 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 49 | gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= 50 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 51 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 52 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 53 | -------------------------------------------------------------------------------- /examples/gqlgen-service/go.sum: -------------------------------------------------------------------------------- 1 | github.com/99designs/gqlgen v0.17.44 h1:OS2wLk/67Y+vXM75XHbwRnNYJcbuJd4OBL76RX3NQQA= 2 | github.com/99designs/gqlgen v0.17.44/go.mod h1:UTCu3xpK2mLI5qcMNw+HKDiEL77it/1XtAjisC4sLwM= 3 | github.com/agnivade/levenshtein v1.1.1 h1:QY8M92nrzkmr798gCo3kmMyqXFzdQVpxLlGPRBij0P8= 4 | github.com/agnivade/levenshtein v1.1.1/go.mod h1:veldBMzWxcCG2ZvUTKD2kJNRdCk5hVbJomOvKkmgYbo= 5 | github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ= 6 | github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8= 7 | github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0 h1:jfIu9sQUG6Ig+0+Ap1h4unLjW6YQJpKZVmUzxsD4E/Q= 8 | github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE= 9 | github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w= 10 | github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= 11 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 12 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 13 | github.com/dgryski/trifles v0.0.0-20200323201526-dd97f9abfb48 h1:fRzb/w+pyskVMQ+UbP35JkH8yB7MYb4q/qhBarqZE6g= 14 | github.com/dgryski/trifles v0.0.0-20200323201526-dd97f9abfb48/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA= 15 | github.com/go-faker/faker/v4 v4.0.0-beta.3 h1:zjTxJMHn7Po7OCPKY+VjO6mNQ4ZzE7PoBjb2sUNHVPs= 16 | github.com/go-faker/faker/v4 v4.0.0-beta.3/go.mod h1:uuNc0PSRxF8nMgjGrrrU4Nw5cF30Jc6Kd0/FUTTYbhg= 17 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 18 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 19 | github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= 20 | github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 21 | github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= 22 | github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= 23 | github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= 24 | github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= 25 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 26 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 27 | github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= 28 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 29 | github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= 30 | github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= 31 | github.com/sosodev/duration v1.2.0 h1:pqK/FLSjsAADWY74SyWDCjOcd5l7H8GSnnOGEB9A1Us= 32 | github.com/sosodev/duration v1.2.0/go.mod h1:RQIBBX0+fMLc/D9+Jb/fwvVmo0eZvDDEERAikUR6SDg= 33 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= 34 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 35 | github.com/urfave/cli/v2 v2.27.1 h1:8xSQ6szndafKVRmfyeUMxkNUJQMjL1F2zmsZ+qHpfho= 36 | github.com/urfave/cli/v2 v2.27.1/go.mod h1:8qnjx1vcq5s2/wpsqoZFndg2CE5tNFyrTvS6SinrnYQ= 37 | github.com/vektah/gqlparser/v2 v2.5.15 h1:fYdnU8roQniJziV5TDiFPm/Ff7pE8xbVSOJqbsdl88A= 38 | github.com/vektah/gqlparser/v2 v2.5.15/go.mod h1:WQQjFc+I1YIzoPvZBhUQX7waZgg3pMLi0r8KymvAE2w= 39 | github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU= 40 | github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8= 41 | golang.org/x/mod v0.14.0 h1:dGoOF9QVLYng8IHTm7BAyWqCqSheQ5pYWGhzW00YJr0= 42 | golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= 43 | golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ= 44 | golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 45 | golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= 46 | golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 47 | golang.org/x/tools v0.17.0 h1:FvmRgNOcs3kOa+T20R1uhfP9F6HgG2mfxDv1vrx1Htc= 48 | golang.org/x/tools v0.17.0/go.mod h1:xsh6VxdV005rRVaS6SSAf9oiAqljS7UZUacMZ8Bnsps= 49 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 50 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 51 | gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= 52 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 53 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 54 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 55 | -------------------------------------------------------------------------------- /docs/plugins.md: -------------------------------------------------------------------------------- 1 | # Plugins 2 | 3 | ## Admin UI 4 | 5 | Admin UI provides a simple administration interface displaying the federated services. 6 | 7 | ```json 8 | { 9 | "name": "admin-ui" 10 | } 11 | ``` 12 | 13 | You access the Admin UI by visiting `http://localhost:/admin` in your browser. 14 | 15 | ## CORS 16 | 17 | Add `CORS` headers to queries. 18 | 19 | ```json 20 | { 21 | "name": "cors", 22 | "config": { 23 | "allowed-origins": ["https://example.com"], 24 | "allowed-headers": ["X-Custom-Header"], 25 | "allow-credentials": true, 26 | "max-age": 3600, 27 | "debug": true 28 | } 29 | } 30 | ``` 31 | 32 | ## Headers 33 | 34 | Allow headers to passthrough to downstream services. 35 | 36 | ```json 37 | { 38 | "name": "headers", 39 | "config": { 40 | "allowed-headers": ["X-Custom-Header"] 41 | } 42 | } 43 | ``` 44 | 45 | ## JWT Auth 46 | 47 | The JWT auth plugin validates that the request contains a valid JWT and 48 | provides roles support. 49 | 50 | #### Public keys 51 | 52 | Public keys can be provided through: 53 | 54 | - JWKS endpoints 55 | - Manually in the config 56 | 57 | #### JWT 58 | 59 | The plugin checks for the JWT in: 60 | 61 | - The `Authorization` header: `Authorization: Bearer ` 62 | - The `token` cookie 63 | 64 | #### Roles 65 | 66 | The JWT must contains a `role` claim with a valid role (as defined in the 67 | config). 68 | 69 | A role is a named set of permissions (as described in [access 70 | control](access-control.md)). 71 | When receiving a query with a valid JWT the permissions associated with the role will be added to the query. 72 | 73 | !> **If a JWT is not present in the request, the request will proceed with the `public_role` role.** 74 | So be sure to leave the `public_role` role empty is you do not want any unauthenticated access. 75 | 76 | #### Configuration 77 | 78 | ```json 79 | { 80 | "name": "auth-jwt", 81 | "config": { 82 | "JWKS": ["http://example.com/keys.jwks"], 83 | "public-keys": { 84 | "my-kid": "PUBLIC KEY" 85 | }, 86 | "roles": { 87 | // example public role, allow only login mutation 88 | "public_role": { 89 | "mutation": { 90 | "auth": ["login"] 91 | } 92 | }, 93 | // example internal role, allow all 94 | "internal": { 95 | "query": "*", 96 | "mutation": "*" 97 | } 98 | } 99 | } 100 | } 101 | ``` 102 | 103 | ## Limits 104 | 105 | Set limits for response time and incoming requests size. 106 | 107 | ```json 108 | { 109 | "name": "limits", 110 | "config": { 111 | "max-response-time": "10s", 112 | "max-request-bytes": 1000000 113 | } 114 | } 115 | ``` 116 | 117 | The limit for response time is applied to each sub-query. If a sub-query times out, it will return a null value with a corresponding error denoting the problematic selection set. 118 | 119 | e.g. 120 | 121 | ```json 122 | { 123 | "errors": { 124 | "message": "Post \"http://localhost:8080/query\": context deadline exceeded", 125 | "extensions": { 126 | "selectionSet": "{ serviceB _bramble_id: id }" 127 | }, 128 | ... 129 | }, 130 | "data": { 131 | "serviceA": "quick answer", 132 | "serviceB": null 133 | } 134 | } 135 | ``` 136 | 137 | ## Meta 138 | 139 | Adds meta-information to the graph. 140 | 141 | ```json 142 | { 143 | "name": "meta" 144 | } 145 | ``` 146 | 147 | With the Meta plugin, you can programmatically query Bramble's federation information. The typical use case for this plugin is to build tooling around Bramble (e.g. a schema explorer that show which service exposes each field). 148 | 149 | The Meta plugin federates the following GraphQL API in your graph: 150 | 151 | ```graphql 152 | type BrambleService @boundary { 153 | id: ID! 154 | name: String! 155 | version: String! 156 | schema: String! 157 | status: String! 158 | serviceUrl: String! 159 | } 160 | 161 | type BrambleFieldArgument { 162 | name: String! 163 | type: String! 164 | } 165 | 166 | type BrambleField @boundary { 167 | id: ID! 168 | name: String! 169 | type: String! 170 | service: String! 171 | arguments: [BrambleFieldArgument!]! 172 | description: String 173 | } 174 | 175 | type BrambleEnumValue { 176 | name: String! 177 | description: String 178 | } 179 | 180 | type BrambleType @boundary { 181 | kind: String! 182 | name: String! 183 | directives: [String!]! 184 | fields: [BrambleField!]! 185 | enumValues: [BrambleEnumValue!]! 186 | description: String 187 | } 188 | 189 | type BrambleSchema { 190 | types: [BrambleType!]! 191 | } 192 | 193 | type BrambleMetaQuery @namespace { 194 | services: [BrambleService!]! 195 | schema: BrambleSchema! 196 | field(id: ID!): BrambleField 197 | } 198 | 199 | extend type Query { 200 | meta: BrambleMetaQuery! 201 | } 202 | ``` 203 | 204 | Note that the Meta plugin offers an extensible schema since `BrambleMetaQuery` is a namespace and `BrambleField`, `BrambleType`, and `BrambleService` are all boundary types. 205 | 206 | ## Playground 207 | 208 | Exposes the GraphQL playground on `/playground`. 209 | 210 | ```json 211 | { 212 | "name": "playground" 213 | } 214 | ``` 215 | 216 | You access the GraphQL playground by visiting `http://localhost:/playground` in your browser. 217 | -------------------------------------------------------------------------------- /merge_fixtures_test.go: -------------------------------------------------------------------------------- 1 | package bramble 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | "github.com/stretchr/testify/require" 8 | "github.com/vektah/gqlparser/v2" 9 | "github.com/vektah/gqlparser/v2/ast" 10 | ) 11 | 12 | type MergeTestFixture struct { 13 | Input1 string 14 | Input2 string 15 | Expected string 16 | Error string 17 | } 18 | 19 | type BuildFieldURLMapFixture struct { 20 | Schema1 string 21 | Location1 string 22 | Schema2 string 23 | Location2 string 24 | Expected FieldURLMap 25 | } 26 | 27 | func (f MergeTestFixture) CheckSuccess(t *testing.T) { 28 | t.Helper() 29 | var schemas []*ast.Schema 30 | if f.Input1 != "" { 31 | schemas = append(schemas, loadSchema(f.Input1)) 32 | } 33 | if f.Input2 != "" { 34 | schemas = append(schemas, loadSchema(f.Input2)) 35 | } 36 | actual := mustMergeSchemas(t, schemas...) 37 | 38 | // If resulting Query type is empty, remove it from schema to avoid 39 | // generating an invalid schema when formatting (empty Query type: `type Query {}`) 40 | if actual.Query != nil && len(filterBuiltinFields(actual.Query.Fields)) == 0 { 41 | delete(actual.Types, "Query") 42 | delete(actual.PossibleTypes, "Query") 43 | } 44 | 45 | assertSchemaConsistency(t, actual) 46 | assert.Equal(t, loadAndFormatSchema(f.Expected), formatSchema(actual)) 47 | } 48 | 49 | func (f MergeTestFixture) CheckError(t *testing.T) { 50 | t.Helper() 51 | var schemas []*ast.Schema 52 | if f.Input1 != "" { 53 | schemas = append(schemas, loadSchema(f.Input1)) 54 | } 55 | if f.Input2 != "" { 56 | schemas = append(schemas, loadSchema(f.Input2)) 57 | } 58 | _, err := MergeSchemas(schemas...) 59 | assert.Error(t, err) 60 | assert.Equal(t, f.Error, err.Error()) 61 | } 62 | 63 | func (f BuildFieldURLMapFixture) Check(t *testing.T) { 64 | t.Helper() 65 | var services []*Service 66 | if f.Schema1 != "" { 67 | services = append( 68 | services, 69 | &Service{ServiceURL: f.Location1, Schema: loadSchema(f.Schema1)}, 70 | ) 71 | } 72 | if f.Schema2 != "" { 73 | services = append( 74 | services, 75 | &Service{ServiceURL: f.Location2, Schema: loadSchema(f.Schema2)}, 76 | ) 77 | } 78 | locations := buildFieldURLMap(services...) 79 | assert.Equal(t, f.Expected, locations) 80 | } 81 | 82 | func loadSchema(input string) *ast.Schema { 83 | return gqlparser.MustLoadSchema(&ast.Source{Name: "schema", Input: input}) 84 | } 85 | 86 | func loadAndFormatSchema(input string) string { 87 | return formatSchema(loadSchema(input)) 88 | } 89 | 90 | func mustMergeSchemas(t *testing.T, sources ...*ast.Schema) *ast.Schema { 91 | t.Helper() 92 | s, err := MergeSchemas(sources...) 93 | require.NoError(t, err) 94 | return s 95 | } 96 | 97 | func assertSchemaConsistency(t *testing.T, schema *ast.Schema) { 98 | t.Helper() 99 | assertSchemaImplementsConsistency(t, schema) 100 | assertSchemaPossibleTypesConsistency(t, schema) 101 | assertSchemaIntrospectionTypes(t, schema) 102 | assertSchemaBuiltinDirectives(t, schema) 103 | } 104 | 105 | func assertSchemaImplementsConsistency(t *testing.T, schema *ast.Schema) { 106 | t.Helper() 107 | actual := getImplements(schema) 108 | expected := getImplements(loadSchema(formatSchema(schema))) 109 | assert.Equal(t, expected, actual, "schema.Implements is not consistent") 110 | } 111 | 112 | func assertSchemaPossibleTypesConsistency(t *testing.T, schema *ast.Schema) { 113 | t.Helper() 114 | actual := getPossibleTypes(schema) 115 | expected := getPossibleTypes(loadSchema(formatSchema(schema))) 116 | actualKeys, expectedKeys := []string{}, []string{} 117 | for key := range actual { 118 | actualKeys = append(actualKeys, key) 119 | } 120 | for key := range expected { 121 | expectedKeys = append(expectedKeys, key) 122 | } 123 | assert.ElementsMatch(t, expectedKeys, actualKeys, "schema.PossibleTypes is not consistent") 124 | for typeName := range actual { 125 | assert.ElementsMatchf(t, actual[typeName], expected[typeName], "schema.PossibleTypes[%s] is not consistent", typeName) 126 | } 127 | } 128 | 129 | func assertSchemaIntrospectionTypes(t *testing.T, schema *ast.Schema) { 130 | t.Helper() 131 | emptyAST := gqlparser.MustLoadSchema(&ast.Source{Name: "empty", Input: ""}) 132 | fields := []string{"__Schema", "__Directive", "__DirectiveLocation", "__EnumValue", "__Field", "__Type", "__TypeKind"} 133 | for _, field := range fields { 134 | assert.Equal(t, ast.Dump(emptyAST.Types[field]), ast.Dump(schema.Types[field]), "introspection field '%s' is missing", field) 135 | } 136 | } 137 | 138 | func assertSchemaBuiltinDirectives(t *testing.T, schema *ast.Schema) { 139 | t.Helper() 140 | emptyAST := gqlparser.MustLoadSchema(&ast.Source{Name: "empty", Input: ""}) 141 | builtInDirectives := []string{"skip", "include", "deprecated"} 142 | for _, d := range builtInDirectives { 143 | expected := emptyAST.Directives[d] 144 | actual := schema.Directives[d] 145 | assert.Equal(t, ast.Dump(expected), ast.Dump(actual)) 146 | } 147 | } 148 | 149 | func getPossibleTypes(schema *ast.Schema) map[string][]string { 150 | result := map[string][]string{} 151 | for k, v := range schema.PossibleTypes { 152 | for _, def := range v { 153 | result[k] = append(result[k], def.Name) 154 | } 155 | } 156 | return result 157 | } 158 | 159 | func getImplements(schema *ast.Schema) map[string][]string { 160 | result := map[string][]string{} 161 | for k, v := range schema.Implements { 162 | for _, def := range v { 163 | result[k] = append(result[k], def.Name) 164 | } 165 | } 166 | return result 167 | } 168 | -------------------------------------------------------------------------------- /docs/sharing-types.md: -------------------------------------------------------------------------------- 1 | # Sharing types across services 2 | 3 | Regular types cannot be shared across services, there are however two exceptions: boundary types and namespaces. 4 | 5 | For more details, see the [federation specification](federation.md). 6 | 7 | ## Boundary types 8 | 9 | ### Introduction 10 | 11 | A boundary type is a type shared by multiple services, where each service adds its own (non overlapping) fields to the type. 12 | 13 | This is very useful when different services want to define different behaviours on a type. 14 | 15 | For example we could have multiple services defining and enriching a `Movie` type: 16 | 17 | ```graphql 18 | type Movie { 19 | title: String! # Defined in service A 20 | posterUrl: String! # Defined in service B 21 | } 22 | 23 | type Query { 24 | movieSearch(title: String!): [Movie!] 25 | } 26 | ``` 27 | 28 | And the user transparently queries that type: 29 | 30 | ```graphql 31 | query { 32 | movieSearch(title: "Source Code") { 33 | title 34 | posterUrl 35 | } 36 | } 37 | ``` 38 | 39 | boundary types merge 40 | 41 | ### Creating a boundary type 42 | 43 | Here are the steps to make a type a boundary type: 44 | 45 | 1. **Add the `@boundary` directive** 46 | 47 | ```graphql 48 | type Movie @boundary { 49 | id: ID! 50 | title: String! 51 | } 52 | ``` 53 | 54 | This tells Bramble that the type can be merged with others. 55 | 56 | !> Boundary types must have an `id: ID!` field. This id must be common across services for a given object. 57 | 58 | ?> **A note on boundary types and nullability**
59 | As with regular GraphQL types, a null response can sometimes have big 60 | repercussions as a null value will bubble up to the first nullable field.
61 | This is no different with boundary types, so when extending a boundary type 62 | make sure fields are nullable if your service will sometimes return no 63 | response for a given ID. 64 | 65 | 2. **Add and implement boundary queries** 66 | 67 | ```graphql 68 | extend Query { 69 | movie(id: ID!): Movie @boundary 70 | } 71 | ``` 72 | 73 | For Bramble to be able to request an arbitrary boundary object, every service 74 | defining boundary types must also implement a boundary query for each 75 | boundary object. 76 | This query takes exactly one id argument and returns the associated object. 77 | 78 | There are no restrictions on the name of a boundary query or its argument, 79 | only the return type is used to determine the matching boundary object. 80 | 81 | **Array syntax** 82 | 83 | When possible, it's better to batch records by defining the boundary query as an array: 84 | 85 | ```graphql 86 | extend Query { 87 | movies(ids: [ID!]!): [Movie]! @boundary 88 | } 89 | ``` 90 | 91 | With this syntax, Bramble will query multiple IDs as a set instead of requesting 92 | each record individually. This can make services more performant by 93 | reducing the need for dataloaders and lowering the overall query complexity. 94 | 95 | There are again no restrictions on the name of the boundary query or its argument. 96 | The resulting array expects to be a _mapped set_ matching the input length and order, 97 | with any missing records padded by null values. 98 | 99 | _Bramble query with regular boundary query_ 100 | 101 | ```graphql 102 | { 103 | _0: movie(id: "1") { 104 | id 105 | title 106 | } 107 | _1: movie(id: "2") { 108 | id 109 | title 110 | } 111 | } 112 | ``` 113 | 114 | _Bramble query with array boundary query_ 115 | 116 | ```graphql 117 | { 118 | _result: movies(ids: ["1", "2"]) { 119 | id 120 | title 121 | } 122 | } 123 | ``` 124 | 125 | ### How it works 126 | 127 | When dealing with boundary types, Bramble will split the query into multiple steps: 128 | 129 | 1. Execute the root query 130 | 2. Execute boundary queries on the previous result 131 | 3. Merge the results 132 | 133 | For example: 134 | 135 | _Schema_ 136 | 137 | ```graphql 138 | type Movie { 139 | id: ID! 140 | title: String! # Defined in service A 141 | compTitles: [Movie!] # Defined in service B 142 | } 143 | 144 | type Query { 145 | movieSearch(title: String!): [Movie!] 146 | } 147 | ``` 148 | 149 | _Query_ 150 | 151 | ```graphql 152 | query { 153 | movieSearch(title: "Tenet") { 154 | title 155 | compTitles { 156 | title 157 | } 158 | } 159 | } 160 | ``` 161 | 162 | _Execution_ 163 | 164 | plan 165 | 166 | ## Namespaces 167 | 168 | A namespace is a type that can be shared among services for the purpose of... namespacing. 169 | 170 | Namespaces types must respect the following rules: 171 | 172 | - Use the `@namespace` directive 173 | - End with `Query` or `Mutation` (e.g. `MyNamespaceQuery`) 174 | - Can only be returned by a root field or another namespace. 175 | - Have no arguments 176 | 177 | ### Example 178 | 179 | _Service A_ 180 | 181 | ```graphql 182 | directive @namespace on OBJECT 183 | 184 | type Query { 185 | myNamespace: MyNamespaceQuery! 186 | } 187 | 188 | type MyNamespaceQuery @namespace { 189 | serviceA: String! 190 | } 191 | ``` 192 | 193 | _Service B_ 194 | 195 | ```graphql 196 | directive @namespace on OBJECT 197 | 198 | type Query { 199 | myNamespace: MyNamespaceQuery! 200 | } 201 | 202 | type MyNamespaceQuery @namespace { 203 | serviceB: String! 204 | } 205 | ``` 206 | 207 | _Merged Schema_ 208 | 209 | ```graphql 210 | type Query { 211 | myNamespace: MyNamespaceQuery! 212 | } 213 | 214 | type MyNamespaceQuery { 215 | serviceA: String! 216 | serviceB: String! 217 | } 218 | ``` 219 | -------------------------------------------------------------------------------- /plugins/admin_ui.html.template: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Admin 5 | 150 | 151 | 152 | 153 |

Aggregated services

154 |
    155 | {{range .Services}} 156 |
  • 157 |
    158 |

    {{.Name}}

    159 |
    {{.Version}}
    160 |
    {{.ServiceURL}}
    161 |
    {{.Status}}
    162 |
    163 | 170 |
  • 171 | {{end}} 172 |
173 |

Test schema merge

174 | {{if ne .TestedSchema "" }} 175 |
176 | {{if eq .TestSchemaError ""}} 177 |

178 | Schema merged successfully! 179 |

180 | 187 | {{else}} 188 |

189 | {{.TestSchemaError}} 190 |

191 | {{end}} 192 |
193 | {{end}} 194 |
195 | 197 | 198 |
199 | 200 | 201 | 202 | -------------------------------------------------------------------------------- /client_test.go: -------------------------------------------------------------------------------- 1 | package bramble 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "net/http/cookiejar" 7 | "net/http/httptest" 8 | "net/url" 9 | "testing" 10 | 11 | "github.com/99designs/gqlgen/graphql" 12 | "github.com/stretchr/testify/assert" 13 | "github.com/stretchr/testify/require" 14 | ) 15 | 16 | func TestGraphqlClient(t *testing.T) { 17 | t.Run("basic request", func(t *testing.T) { 18 | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 19 | w.Write([]byte(`{ 20 | "data": { 21 | "root": { 22 | "test": "value" 23 | } 24 | } 25 | }`)) 26 | })) 27 | 28 | c := NewClient() 29 | var res struct { 30 | Root struct { 31 | Test string 32 | } 33 | } 34 | 35 | err := c.Request(context.Background(), srv.URL, &Request{}, &res) 36 | assert.NoError(t, err) 37 | assert.Equal(t, "value", res.Root.Test) 38 | }) 39 | 40 | t.Run("without keep-alive", func(t *testing.T) { 41 | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 42 | require.Equal(t, "close", r.Header.Get("Connection")) 43 | w.Write([]byte(`{ 44 | "data": { 45 | "root": { 46 | "test": "value" 47 | } 48 | } 49 | }`)) 50 | })) 51 | 52 | c := NewClientWithoutKeepAlive() 53 | err := c.Request(context.Background(), srv.URL, &Request{}, nil) 54 | assert.NoError(t, err) 55 | }) 56 | 57 | t.Run("with http client", func(t *testing.T) { 58 | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 59 | cookie, err := r.Cookie("test_cookie") 60 | require.NoError(t, err) 61 | assert.Equal(t, "test_value", cookie.Value) 62 | })) 63 | 64 | jar, err := cookiejar.New(nil) 65 | require.NoError(t, err) 66 | 67 | serverURL, err := url.Parse(srv.URL) 68 | require.NoError(t, err) 69 | 70 | jar.SetCookies(serverURL, []*http.Cookie{ 71 | { 72 | 73 | Name: "test_cookie", 74 | Value: "test_value", 75 | }, 76 | }) 77 | 78 | httpClient := &http.Client{Jar: jar} 79 | c := NewClient(WithHTTPClient(httpClient)) 80 | var res interface{} 81 | _ = c.Request(context.Background(), srv.URL, &Request{}, &res) 82 | }) 83 | 84 | t.Run("with user agent", func(t *testing.T) { 85 | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 86 | assert.Equal(t, "My User Agent", r.Header.Get("User-Agent")) 87 | })) 88 | 89 | c := NewClient(WithUserAgent("My User Agent")) 90 | var res interface{} 91 | _ = c.Request(context.Background(), srv.URL, &Request{}, &res) 92 | }) 93 | 94 | t.Run("with max response size", func(t *testing.T) { 95 | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 96 | w.Write([]byte(`{ "data": "long response" }`)) 97 | })) 98 | 99 | c := NewClient(WithMaxResponseSize(1)) 100 | var res interface{} 101 | err := c.Request(context.Background(), srv.URL, &Request{}, &res) 102 | require.Error(t, err) 103 | assert.Equal(t, "response exceeded maximum size of 1 bytes", err.Error()) 104 | }) 105 | } 106 | func TestMultipartClient(t *testing.T) { 107 | nestedMap := map[string]any{ 108 | "node1": map[string]any{ 109 | "node11": map[string]any{ 110 | "leaf111": graphql.Upload{}, 111 | "leaf112": "someThing", 112 | "node113": map[string]any{"leaf1131": graphql.Upload{}}, 113 | }, 114 | "leaf12": 42, 115 | "leaf13": graphql.Upload{}, 116 | }, 117 | "node2": map[string]any{ 118 | "leaf21": false, 119 | "node21": map[string]any{ 120 | "leaf211": &graphql.Upload{}, 121 | }, 122 | }, 123 | "node3": graphql.Upload{}, 124 | "node4": []graphql.Upload{{}, {}}, 125 | "node5": []*graphql.Upload{{}, {}}, 126 | } 127 | 128 | t.Run("parseMultipartVariables", func(t *testing.T) { 129 | _, fileMap := prepareUploadsFromVariables(nestedMap) 130 | fileMapKeys := []string{} 131 | fileMapValues := []string{} 132 | for k, v := range fileMap { 133 | fileMapKeys = append(fileMapKeys, k) 134 | fileMapValues = append(fileMapValues, v...) 135 | } 136 | assert.ElementsMatch(t, fileMapKeys, []string{"file0", "file1", "file2", "file3", "file4", "file5", "file6", "file7", "file8"}) 137 | assert.ElementsMatch(t, fileMapValues, []string{ 138 | "variables.node1.node11.node113.leaf1131", 139 | "variables.node1.node11.leaf111", 140 | "variables.node1.leaf13", 141 | "variables.node2.node21.leaf211", 142 | "variables.node3", 143 | "variables.node4.0", 144 | "variables.node4.1", 145 | "variables.node5.0", 146 | "variables.node5.1", 147 | }) 148 | assert.Equal( 149 | t, 150 | map[string]any{ 151 | "node1": map[string]any{ 152 | "node11": map[string]any{ 153 | "leaf111": nil, 154 | "leaf112": "someThing", 155 | "node113": map[string]any{"leaf1131": nil}, 156 | }, 157 | "leaf12": 42, 158 | "leaf13": nil, 159 | }, 160 | "node2": map[string]any{ 161 | "leaf21": false, 162 | "node21": map[string]any{ 163 | "leaf211": nil, 164 | }, 165 | }, 166 | "node3": nil, 167 | "node4": []*struct{}{nil, nil}, 168 | "node5": []*struct{}{nil, nil}, 169 | }, 170 | nestedMap, 171 | ) 172 | }) 173 | 174 | t.Run("multipart request", func(t *testing.T) { 175 | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 176 | w.Write([]byte(`{ "data": {"root": "multipart response"} }`)) 177 | })) 178 | 179 | c := NewClient() 180 | req := &Request{Headers: make(http.Header)} 181 | req.Headers.Set("Content-Type", "multipart/form-data") 182 | 183 | var res struct { 184 | Root string 185 | } 186 | err := c.Request( 187 | context.Background(), 188 | srv.URL, 189 | req, 190 | &res, 191 | ) 192 | require.NoError(t, err) 193 | assert.Equal(t, "multipart response", res.Root) 194 | }) 195 | } 196 | -------------------------------------------------------------------------------- /docs/access-control.md: -------------------------------------------------------------------------------- 1 | # Access Control 2 | 3 | Bramble has a simple (yet powerful) access control mechanism allowing plugins to define field-level permissions for incoming queries. 4 | 5 | !> Permissions must be added to incoming queries through plugins, use the provided [JWT plugin](/plugins?id=jwt-auth) or learn [how to write a plugin](write-plugin.md). 6 | 7 | ## OperationPermissions 8 | 9 | The [`OperationPermissions`](https://pkg.go.dev/github.com/movio/bramble/bramble#OperationPermissions) type defines which fields can be requested for a given query. 10 | 11 | By adding an `OperationPermissions` to the query context it is possible to control the allowed fields for that query. 12 | 13 | At every level of the schema it is possible to: 14 | 15 | - Enable a list of white-listed subfields 16 | or 17 | - Enable all subfields 18 | 19 | ### JSON Representation 20 | 21 | `OperationPermissions` implements a custom JSON unmarshaller, so it is possible to represent the permissions with a more accessible representation. 22 | 23 | ```json 24 | { 25 | "query": { 26 | "movies": "*", 27 | "cinemas": ["id", "name"] 28 | } 29 | } 30 | ``` 31 | 32 | The syntax is as follow: 33 | 34 | - `"*"`: Allows all sub-fields 35 | - An array of fields `[field1, field2]`: Allows the specified fields and all their sub-fields 36 | 37 | See below for more examples. 38 | 39 | ### Examples 40 | 41 | Let's imagine we have the following schema: 42 | 43 | ```graphql 44 | type Cast { 45 | firstName: String! 46 | lastName: String! 47 | } 48 | 49 | type Movie { 50 | id: ID! 51 | title: String! 52 | releaseYear: Int! 53 | cast: [Cast!] 54 | } 55 | 56 | type Cinema { 57 | id: ID! 58 | name: String! 59 | location: String! 60 | } 61 | 62 | type Query { 63 | movies: [Movie!] 64 | cinemas: [Cinemas!] 65 | } 66 | ``` 67 | 68 | #### AllowAll and sub-fields 69 | 70 | If we want to allow the user to query every field on `Movie` but only a subset for `Cinema` we could define the permissions as: 71 | 72 | ```go 73 | OperationPermissions{ 74 | AllowedRootQueryFields: AllowedFields{ 75 | AllowedSubFields: map[string]AllowedFields{ 76 | "movies": AllowedField{ 77 | AllowAll: true, 78 | } 79 | "cinemas": AllowedField{ 80 | AllowedSubFields: map[string]AllowedField{ 81 | "id": AllowedField{}, 82 | "name": AllowedField{}, 83 | } 84 | } 85 | } 86 | } 87 | } 88 | ``` 89 | 90 | This can be more easily represented with JSON: 91 | 92 | ```json 93 | { 94 | "query": { 95 | "movies": "*", 96 | "cinemas": ["id", "name"] 97 | } 98 | } 99 | ``` 100 | 101 | Now if we try to execute the following query: 102 | 103 | ```graphql 104 | query { 105 | cinemas { 106 | name 107 | location 108 | } 109 | } 110 | ``` 111 | 112 | Bramble will filter out unauthorized fields and execute 113 | 114 | ```graphql 115 | query { 116 | cinemas { 117 | name 118 | } 119 | } 120 | ``` 121 | 122 | #### Allow all 123 | 124 | ```json 125 | { 126 | "query": "*", 127 | "mutation": "*" 128 | } 129 | ``` 130 | 131 | will give access to every query and mutation. 132 | 133 | #### Nested fields 134 | 135 | If we want to allow only `movies.title` and `movies.cast.firstName` we can write something like: 136 | 137 | ```json 138 | { 139 | "query": { 140 | "movies": { 141 | "title": [], 142 | "cast": { 143 | "firstName": [] 144 | } 145 | } 146 | } 147 | } 148 | ``` 149 | 150 | On the other hand, using the array notation 151 | 152 | ```json 153 | { 154 | "query": { 155 | "movies": ["title", "cast"] 156 | } 157 | } 158 | ``` 159 | 160 | would give access to all subfields of the named fields so that 161 | 162 | ```graphql 163 | query { 164 | movies { 165 | title 166 | cast { 167 | firstName 168 | lastName 169 | } 170 | } 171 | } 172 | ``` 173 | 174 | is valid. 175 | 176 | ### Testing the permissions 177 | 178 | It is possible to programmatically check what the allowed schema would be for 179 | a given set of permissions by using the `OperationPermissions.FilterSchema` 180 | method. 181 | 182 |
183 | Example Go code 184 | 185 | Here is an example `PrintFilteredSchema` function thats prints the filtered schema from the source schema and JSON permissions. 186 | 187 | ```go 188 | import ( 189 | "bytes" 190 | "encoding/json" 191 | "fmt" 192 | 193 | "github.com/vektah/gqlparser/v2" 194 | "github.com/vektah/gqlparser/v2/ast" 195 | "github.com/vektah/gqlparser/v2/formatter" 196 | ) 197 | 198 | func PrintFilteredSchema(schema, permissionsJSON string) { 199 | var perms OperationPermissions 200 | _ = json.Unmarshal([]byte(permissionsJSON), &perms) 201 | parsedSchema := gqlparser.MustLoadSchema(&ast.Source{Input: schema}) 202 | 203 | filteredSchema := perms.FilterSchema(parsedSchema) 204 | 205 | fmt.Println(formatSchema(filteredSchema)) 206 | } 207 | 208 | func formatSchema(schema *ast.Schema) string { 209 | buf := bytes.NewBufferString("") 210 | f := formatter.NewFormatter(buf) 211 | f.FormatSchema(schema) 212 | return buf.String() 213 | } 214 | ``` 215 | 216 |
217 | 218 | ## Setting permissions for incoming requests 219 | 220 | From a plugin it is possible to add permissions to any incoming request context using `bramble.AddPermissionsToContext`. 221 | 222 | ```go 223 | func (p *MyPlugin) ApplyMiddlewarePublicMux(h http.Handler) http.Handler { 224 | return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { 225 | r = r.WithContext(bramble.AddPermissionsToContext(r.Context(), permissions)) 226 | h.ServeHTTP(rw, r) 227 | } 228 | } 229 | ``` 230 | 231 | If Bramble finds permissions in a query context it will automatically filter out unauthorized fields. 232 | 233 | If a query contains both authorized and unauthorized fields: 234 | 235 | - unauthorized fields will be removed from the query 236 | - an error will be added to the response 237 | - **the query will still proceed with authorized fields** 238 | 239 | ## JWT and role based access control 240 | 241 | Bramble provides a simple plugin for JWT and role based access control. 242 | 243 | The permissions syntax described above can be used to configure roles (i.e. a named set of permissions). 244 | 245 | See [JWT Auth plugin](/plugins?id=jwt-auth) 246 | 247 | !> When using roles on a public-facing instance it is recommended to use 248 | fine-grained whitelisting to avoid newly federated services to inadvertently 249 | expose new fields publicly. 250 | -------------------------------------------------------------------------------- /gateway_test.go: -------------------------------------------------------------------------------- 1 | package bramble 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "log/slog" 9 | "net/http" 10 | "net/http/httptest" 11 | "strings" 12 | "testing" 13 | 14 | "github.com/stretchr/testify/assert" 15 | "github.com/stretchr/testify/require" 16 | ) 17 | 18 | func TestGatewayQuery(t *testing.T) { 19 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 20 | var req struct { 21 | Query string 22 | } 23 | json.NewDecoder(r.Body).Decode(&req) 24 | 25 | if strings.Contains(req.Query, "service") { 26 | // initial query to get schema 27 | schema := `type Service { 28 | name: String! 29 | version: String! 30 | schema: String! 31 | } 32 | 33 | type Query { 34 | test: String 35 | service: Service! 36 | }` 37 | encodedSchema, _ := json.Marshal(schema) 38 | fmt.Fprintf(w, `{ 39 | "data": { 40 | "service": { 41 | "schema": %s, 42 | "version": "1.0", 43 | "name": "test-service" 44 | } 45 | } 46 | }`, string(encodedSchema)) 47 | assert.Equal(t, "Bramble/dev (update)", r.Header.Get("User-Agent")) 48 | } else { 49 | w.Write([]byte(`{ "data": { "test": "Hello" }}`)) 50 | assert.Equal(t, "Bramble/dev (query)", r.Header.Get("User-Agent")) 51 | } 52 | })) 53 | client := NewClient(WithUserAgent(GenerateUserAgent("query"))) 54 | executableSchema := NewExecutableSchema(nil, 50, client, NewService(server.URL)) 55 | err := executableSchema.UpdateSchema(context.TODO(), true) 56 | require.NoError(t, err) 57 | gtw := NewGateway(executableSchema, []Plugin{}) 58 | rec := httptest.NewRecorder() 59 | req := httptest.NewRequest(http.MethodPost, "/query", strings.NewReader(` 60 | { 61 | "query": "query gatewaytest { test }" 62 | }`)) 63 | req.Header.Set("Content-Type", "application/json; charset=utf-8") 64 | req.Header.Set("Accept", "application/json; charset=utf-8") 65 | 66 | gtw.Router(&Config{}).ServeHTTP(rec, req) 67 | assert.Equal(t, http.StatusOK, rec.Code) 68 | assert.JSONEq(t, `{"data": { "test": "Hello" }}`, rec.Body.String()) 69 | } 70 | 71 | func TestRequestNoBodyLoggingOnInfo(t *testing.T) { 72 | server := NewGateway(NewExecutableSchema(nil, 50, nil), nil).Router(&Config{}) 73 | 74 | body := map[string]interface{}{ 75 | "foo": "bar", 76 | } 77 | jr, jw := io.Pipe() 78 | go func() { 79 | enc := json.NewEncoder(jw) 80 | enc.Encode(body) 81 | jw.Close() 82 | }() 83 | defer jr.Close() 84 | 85 | req := httptest.NewRequest("POST", "/query", jr) 86 | req.Header.Set("Content-Type", "application/json") 87 | w := httptest.NewRecorder() 88 | obj := collectLogEvent(t, &slog.HandlerOptions{Level: slog.LevelInfo}, func() { 89 | server.ServeHTTP(w, req) 90 | }) 91 | resp := w.Result() 92 | 93 | assert.NotNil(t, obj) 94 | assert.Equal(t, float64(resp.StatusCode), obj["response.status"]) 95 | assert.Empty(t, obj["request.content-type"]) 96 | assert.Empty(t, obj["request.body"]) 97 | } 98 | 99 | func TestRequestJSONBodyLogging(t *testing.T) { 100 | server := NewGateway(NewExecutableSchema(nil, 50, nil), nil).Router(&Config{}) 101 | 102 | body := map[string]interface{}{ 103 | "foo": "bar", 104 | } 105 | jr, jw := io.Pipe() 106 | go func() { 107 | enc := json.NewEncoder(jw) 108 | enc.Encode(body) 109 | jw.Close() 110 | }() 111 | defer jr.Close() 112 | 113 | req := httptest.NewRequest("POST", "/query", jr) 114 | req.Header.Set("Content-Type", "application/json") 115 | w := httptest.NewRecorder() 116 | obj := collectLogEvent(t, &slog.HandlerOptions{Level: slog.LevelDebug}, func() { 117 | server.ServeHTTP(w, req) 118 | }) 119 | resp := w.Result() 120 | 121 | assert.NotNil(t, obj) 122 | assert.Equal(t, float64(resp.StatusCode), obj["response.status"]) 123 | assert.Equal(t, "application/json", obj["request.content-type"]) 124 | assert.IsType(t, make(map[string]interface{}), obj["request.body"]) 125 | assert.Equal(t, body, obj["request.body"]) 126 | } 127 | 128 | func TestRequestInvalidJSONBodyLogging(t *testing.T) { 129 | server := NewGateway(nil, nil).Router(&Config{}) 130 | 131 | body := `{ "invalid": "json` 132 | jr, jw := io.Pipe() 133 | go func() { 134 | jw.Write([]byte(body)) 135 | jw.Close() 136 | }() 137 | defer jr.Close() 138 | 139 | req := httptest.NewRequest("POST", "/query", jr) 140 | req.Header.Set("Content-Type", "application/json") 141 | w := httptest.NewRecorder() 142 | obj := collectLogEvent(t, &slog.HandlerOptions{Level: slog.LevelDebug}, func() { 143 | server.ServeHTTP(w, req) 144 | }) 145 | w.Result() 146 | 147 | assert.NotNil(t, obj) 148 | assert.Equal(t, "application/json", obj["request.content-type"]) 149 | assert.IsType(t, "string", obj["request.body"]) 150 | assert.Equal(t, body, obj["request.body"]) 151 | assert.Equal(t, "unexpected end of JSON input", obj["request.error"]) 152 | } 153 | 154 | func TestRequestTextBodyLogging(t *testing.T) { 155 | server := NewGateway(nil, nil).Router(&Config{}) 156 | 157 | body := `the request body` 158 | jr, jw := io.Pipe() 159 | go func() { 160 | jw.Write([]byte(body)) 161 | jw.Close() 162 | }() 163 | defer jr.Close() 164 | 165 | req := httptest.NewRequest("POST", "/query", jr) 166 | req.Header.Set("Content-Type", "text/plain") 167 | w := httptest.NewRecorder() 168 | obj := collectLogEvent(t, &slog.HandlerOptions{Level: slog.LevelDebug}, func() { 169 | server.ServeHTTP(w, req) 170 | }) 171 | w.Result() 172 | 173 | assert.NotNil(t, obj) 174 | assert.Equal(t, "text/plain", obj["request.content-type"]) 175 | assert.IsType(t, "string", obj["request.body"]) 176 | assert.Equal(t, body, obj["request.body"]) 177 | assert.Equal(t, nil, obj["request.error"]) 178 | } 179 | 180 | func TestDebugMiddleware(t *testing.T) { 181 | t.Run("without debug header", func(t *testing.T) { 182 | called := false 183 | req := httptest.NewRequest("POST", "/", nil) 184 | h := func(w http.ResponseWriter, r *http.Request) { 185 | called = true 186 | info, ok := r.Context().Value(DebugKey).(DebugInfo) 187 | assert.True(t, ok, "context should include debugInfo") 188 | assert.False(t, info.Variables) 189 | assert.False(t, info.Query) 190 | assert.False(t, info.Plan) 191 | w.WriteHeader(http.StatusOK) 192 | } 193 | server := debugMiddleware(http.HandlerFunc(h)) 194 | w := httptest.NewRecorder() 195 | server.ServeHTTP(w, req) 196 | assert.True(t, called, "handler not called") 197 | }) 198 | for header, expected := range map[string]DebugInfo{ 199 | "all": { 200 | Variables: true, 201 | Query: true, 202 | Plan: true, 203 | }, 204 | "query": { 205 | Query: true, 206 | }, 207 | "variables": { 208 | Variables: true, 209 | }, 210 | "plan": { 211 | Plan: true, 212 | }, 213 | "query plan": { 214 | Query: true, 215 | Plan: true, 216 | }, 217 | } { 218 | t.Run("with debug header value all", func(t *testing.T) { 219 | called := false 220 | req := httptest.NewRequest("POST", "/", nil) 221 | req.Header.Set(debugHeader, header) 222 | h := func(w http.ResponseWriter, r *http.Request) { 223 | called = true 224 | info, ok := r.Context().Value(DebugKey).(DebugInfo) 225 | assert.True(t, ok, "context should include debugInfo") 226 | assert.Equal(t, expected.Variables, info.Variables) 227 | assert.Equal(t, expected.Query, info.Query) 228 | assert.Equal(t, expected.Plan, info.Plan) 229 | w.WriteHeader(http.StatusOK) 230 | } 231 | server := debugMiddleware(http.HandlerFunc(h)) 232 | w := httptest.NewRecorder() 233 | server.ServeHTTP(w, req) 234 | assert.True(t, called, "handler not called") 235 | }) 236 | } 237 | } 238 | -------------------------------------------------------------------------------- /telemetry.go: -------------------------------------------------------------------------------- 1 | package bramble 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | log "log/slog" 7 | "os" 8 | 9 | "go.opentelemetry.io/otel" 10 | "go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc" 11 | "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc" 12 | "go.opentelemetry.io/otel/propagation" 13 | sdkmetric "go.opentelemetry.io/otel/sdk/metric" 14 | "go.opentelemetry.io/otel/sdk/resource" 15 | sdktrace "go.opentelemetry.io/otel/sdk/trace" 16 | semconv "go.opentelemetry.io/otel/semconv/v1.25.0" 17 | ) 18 | 19 | // instrumentationName is used to identify the instrumentation in the 20 | // OpenTelemetry collector. It maps to the attribute `otel.library.name`. 21 | const instrumentationName string = "github.com/movio/bramble" 22 | 23 | // TelemetryConfig is the configuration for OpenTelemetry tracing and metrics. 24 | type TelemetryConfig struct { 25 | Enabled bool `json:"enabled"` // Enabled enables OpenTelemetry tracing and metrics. 26 | Insecure bool `json:"insecure"` // Insecure enables insecure communication with the OpenTelemetry collector. 27 | Endpoint string `json:"endpoint"` // Endpoint is the OpenTelemetry collector endpoint. 28 | ServiceName string `json:"service_name"` // ServiceName is the name of the service. 29 | } 30 | 31 | // InitializesTelemetry initializes OpenTelemetry tracing and metrics. It 32 | // returns a shutdown function that should be called when the application 33 | // terminates. 34 | func InitTelemetry(ctx context.Context, cfg TelemetryConfig) (func(context.Context) error, error) { 35 | endpoint := os.Getenv("BRAMBLE_OTEL_ENDPOINT") 36 | if endpoint != "" { 37 | cfg.Endpoint = endpoint 38 | } 39 | 40 | // If telemetry is disabled, return a no-op shutdown function. The standard 41 | // behaviour of the application will not be affected, since a 42 | // `NoopTracerProvider` is used by default. 43 | if !cfg.Enabled || cfg.Endpoint == "" { 44 | return func(context.Context) error { return nil }, nil 45 | } 46 | 47 | var flushAndShutdownFuncs []func(context.Context) error 48 | 49 | // flushAndShutdown calls cleanup functions registered via shutdownFuncs. 50 | // The errors from the calls are joined. 51 | // Each registered cleanup will be invoked once. 52 | flushAndShutdown := func(ctx context.Context) error { 53 | var err error 54 | for _, fn := range flushAndShutdownFuncs { 55 | err = errors.Join(err, fn(ctx)) 56 | } 57 | flushAndShutdownFuncs = nil 58 | return err 59 | } 60 | 61 | // handleErr calls shutdown for cleanup and makes sure that all errors are returned. 62 | handleErr := func(inErr error) error { 63 | return errors.Join(inErr, flushAndShutdown(ctx)) 64 | } 65 | 66 | if cfg.ServiceName == "" { 67 | cfg.ServiceName = "bramble" 68 | } 69 | 70 | // Set up resource. 71 | res, err := resources(cfg) 72 | if err != nil { 73 | return nil, handleErr(err) 74 | } 75 | 76 | // Set up propagator. 77 | prop := propagation.NewCompositeTextMapPropagator( 78 | propagation.TraceContext{}, 79 | propagation.Baggage{}, 80 | ) 81 | 82 | otel.SetTextMapPropagator(prop) 83 | 84 | otel.SetErrorHandler(otel.ErrorHandlerFunc(func(err error) { 85 | log.Error(err.Error()) 86 | })) 87 | 88 | traceShutdown, err := setupOTelTraceProvider(ctx, cfg, res) 89 | if err != nil { 90 | return nil, handleErr(err) 91 | } 92 | 93 | flushAndShutdownFuncs = append(flushAndShutdownFuncs, traceShutdown...) 94 | 95 | meterShutdown, err := setupOTelMeterProvider(ctx, cfg, res) 96 | if err != nil { 97 | return nil, handleErr(err) 98 | } 99 | 100 | flushAndShutdownFuncs = append(flushAndShutdownFuncs, meterShutdown...) 101 | 102 | return flushAndShutdown, nil 103 | } 104 | 105 | func setupOTelTraceProvider(ctx context.Context, cfg TelemetryConfig, res *resource.Resource) ([]func(context.Context) error, error) { 106 | // Set up exporter. 107 | traceExp, err := newTraceExporter(ctx, cfg.Endpoint, cfg.Insecure) 108 | if err != nil { 109 | return nil, err 110 | } 111 | 112 | // Set up trace provider. 113 | tracerProvider, err := newTraceProvider(traceExp, res) 114 | if err != nil { 115 | return nil, err 116 | } 117 | 118 | var shutdownFuncs []func(context.Context) error 119 | shutdownFuncs = append(shutdownFuncs, 120 | tracerProvider.ForceFlush, // ForceFlush exports any traces that have not yet been exported. 121 | tracerProvider.Shutdown, // Shutdown stops the export pipeline and returns the last error. 122 | ) 123 | 124 | otel.SetTracerProvider(tracerProvider) 125 | return shutdownFuncs, nil 126 | } 127 | 128 | func newTraceExporter(ctx context.Context, endpoint string, insecure bool) (sdktrace.SpanExporter, error) { 129 | exporterOpts := []otlptracegrpc.Option{ 130 | otlptracegrpc.WithEndpoint(endpoint), 131 | } 132 | 133 | if insecure { 134 | exporterOpts = append(exporterOpts, otlptracegrpc.WithInsecure()) 135 | } 136 | 137 | traceExporter, err := otlptracegrpc.New(ctx, exporterOpts...) 138 | if err != nil { 139 | return nil, err 140 | } 141 | 142 | return traceExporter, nil 143 | } 144 | 145 | func newTraceProvider(exp sdktrace.SpanExporter, res *resource.Resource) (*sdktrace.TracerProvider, error) { 146 | // ParentBased sampler is used to sample traces based on the parent span. 147 | // This is useful for sampling traces based on the sampling decision of the 148 | // upstream service. We follow the default sampling strategy of the 149 | // OpenTelemetry Sampler. 150 | parentSamplers := []sdktrace.ParentBasedSamplerOption{ 151 | sdktrace.WithLocalParentSampled(sdktrace.AlwaysSample()), 152 | sdktrace.WithLocalParentNotSampled(sdktrace.NeverSample()), 153 | sdktrace.WithRemoteParentSampled(sdktrace.AlwaysSample()), 154 | sdktrace.WithRemoteParentNotSampled(sdktrace.NeverSample()), 155 | } 156 | 157 | traceProvider := sdktrace.NewTracerProvider( 158 | // By default we'll trace all requests if not parent trace is found. 159 | // Otherwise we follow the rules from above. 160 | sdktrace.WithSampler(sdktrace.ParentBased(sdktrace.AlwaysSample(), parentSamplers...)), 161 | sdktrace.WithResource(res), 162 | sdktrace.WithSpanProcessor(sdktrace.NewBatchSpanProcessor(exp)), 163 | ) 164 | 165 | return traceProvider, nil 166 | } 167 | 168 | func setupOTelMeterProvider(ctx context.Context, cfg TelemetryConfig, res *resource.Resource) ([]func(context.Context) error, error) { 169 | metricExp, err := newMetricExporter(ctx, cfg.Endpoint, cfg.Insecure) 170 | if err != nil { 171 | return nil, err 172 | } 173 | 174 | meterProvider, err := newMeterProvider(metricExp, res) 175 | if err != nil { 176 | return nil, err 177 | } 178 | 179 | var shutdownFuncs []func(context.Context) error 180 | shutdownFuncs = append(shutdownFuncs, 181 | meterProvider.ForceFlush, // ForceFlush exports any metrics that have not yet been exported. 182 | meterProvider.Shutdown, // Shutdown stops the export pipeline and returns the last error. 183 | ) 184 | 185 | otel.SetMeterProvider(meterProvider) 186 | return shutdownFuncs, nil 187 | } 188 | 189 | func newMetricExporter(ctx context.Context, endpoint string, insecure bool) (sdkmetric.Exporter, error) { 190 | exporterOpts := []otlpmetricgrpc.Option{ 191 | otlpmetricgrpc.WithEndpoint(endpoint), 192 | } 193 | 194 | if insecure { 195 | exporterOpts = append(exporterOpts, otlpmetricgrpc.WithInsecure()) 196 | } 197 | 198 | metricExporter, err := otlpmetricgrpc.New(ctx, exporterOpts...) 199 | if err != nil { 200 | return nil, err 201 | } 202 | 203 | return metricExporter, nil 204 | } 205 | 206 | func newMeterProvider(exp sdkmetric.Exporter, res *resource.Resource) (*sdkmetric.MeterProvider, error) { 207 | meterProvider := sdkmetric.NewMeterProvider( 208 | sdkmetric.WithResource(res), 209 | sdkmetric.WithReader(sdkmetric.NewPeriodicReader(exp)), 210 | ) 211 | 212 | return meterProvider, nil 213 | } 214 | 215 | func resources(cfg TelemetryConfig) (*resource.Resource, error) { 216 | return resource.Merge(resource.Default(), 217 | resource.NewWithAttributes(semconv.SchemaURL, 218 | semconv.ServiceName(cfg.ServiceName), 219 | semconv.ServiceVersion(Version), 220 | )) 221 | } 222 | -------------------------------------------------------------------------------- /format.go: -------------------------------------------------------------------------------- 1 | package bramble 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "fmt" 7 | "regexp" 8 | "strconv" 9 | "strings" 10 | 11 | "github.com/99designs/gqlgen/graphql" 12 | "github.com/vektah/gqlparser/v2/ast" 13 | "github.com/vektah/gqlparser/v2/formatter" 14 | ) 15 | 16 | func indentPrefix(sb *strings.Builder, level int, suffix ...string) (int, error) { 17 | sb.WriteString("\n") 18 | 19 | var err error 20 | total, count := 0, 0 21 | for i := 0; i <= level; i++ { 22 | count, err = sb.WriteString(" ") 23 | total += count 24 | if err != nil { 25 | return total, err 26 | } 27 | } 28 | for _, str := range suffix { 29 | count, err = sb.WriteString(str) 30 | total += count 31 | if err != nil { 32 | return total, err 33 | } 34 | } 35 | return total, nil 36 | } 37 | 38 | func formatDocument(ctx context.Context, schema *ast.Schema, operationType string, selectionSet ast.SelectionSet) (string, map[string]interface{}) { 39 | operation, vars := formatOperation(ctx, selectionSet) 40 | return strings.ToLower(operationType) + " " + operation + formatSelectionSet(ctx, schema, selectionSet), vars 41 | } 42 | 43 | func formatOperation(ctx context.Context, selection ast.SelectionSet) (string, map[string]interface{}) { 44 | sb := strings.Builder{} 45 | 46 | if !graphql.HasOperationContext(ctx) { 47 | return "", nil 48 | } 49 | operationCtx := graphql.GetOperationContext(ctx) 50 | 51 | variables := selectionSetVariables(selection) 52 | variableNames := map[string]struct{}{} 53 | for _, s := range variables { 54 | variableNames[s] = struct{}{} 55 | } 56 | 57 | var arguments []string 58 | usedVariables := map[string]interface{}{} 59 | for _, variableDefinition := range operationCtx.Operation.VariableDefinitions { 60 | if _, exists := variableNames[variableDefinition.Variable]; !exists { 61 | continue 62 | } 63 | 64 | for varName, varValue := range operationCtx.Variables { 65 | if varName == variableDefinition.Variable { 66 | usedVariables[varName] = varValue 67 | } 68 | } 69 | 70 | argument := fmt.Sprintf("$%s: %s", variableDefinition.Variable, variableDefinition.Type.String()) 71 | arguments = append(arguments, argument) 72 | } 73 | 74 | sb.WriteString(operationCtx.OperationName) 75 | if len(arguments) == 0 { 76 | return sb.String(), nil 77 | } 78 | 79 | sb.WriteString("(") 80 | sb.WriteString(strings.Join(arguments, ",")) 81 | sb.WriteString(")") 82 | 83 | return sb.String(), usedVariables 84 | } 85 | 86 | func selectionSetVariables(selectionSet ast.SelectionSet) []string { 87 | var vars []string 88 | for _, s := range selectionSet { 89 | switch selection := s.(type) { 90 | case *ast.Field: 91 | vars = append(vars, directiveListVariables(selection.Directives)...) 92 | vars = append(vars, argumentListVariables(selection.Arguments)...) 93 | vars = append(vars, selectionSetVariables(selection.SelectionSet)...) 94 | case *ast.InlineFragment: 95 | vars = append(vars, directiveListVariables(selection.Directives)...) 96 | vars = append(vars, selectionSetVariables(selection.SelectionSet)...) 97 | case *ast.FragmentSpread: 98 | vars = append(vars, directiveListVariables(selection.Directives)...) 99 | } 100 | } 101 | 102 | return vars 103 | } 104 | 105 | func directiveListVariables(directives ast.DirectiveList) []string { 106 | var output []string 107 | for _, d := range directives { 108 | output = append(output, argumentListVariables(d.Arguments)...) 109 | } 110 | 111 | return output 112 | } 113 | 114 | func argumentListVariables(arguments ast.ArgumentList) []string { 115 | var output []string 116 | for _, a := range arguments { 117 | output = append(output, valueVariables(a.Value)...) 118 | } 119 | 120 | return output 121 | } 122 | 123 | func valueVariables(a *ast.Value) []string { 124 | var output []string 125 | switch a.Kind { 126 | case ast.Variable: 127 | output = append(output, a.Raw) 128 | default: 129 | for _, child := range a.Children { 130 | output = append(output, valueVariables(child.Value)...) 131 | } 132 | } 133 | 134 | return output 135 | } 136 | 137 | func formatSelectionSelectionSet(sb *strings.Builder, schema *ast.Schema, vars map[string]interface{}, level int, selectionSet ast.SelectionSet) { 138 | sb.WriteString(" {") 139 | formatSelection(sb, schema, vars, level+1, selectionSet) 140 | indentPrefix(sb, level, "}") 141 | } 142 | 143 | func formatSelection(sb *strings.Builder, schema *ast.Schema, vars map[string]interface{}, level int, selectionSet ast.SelectionSet) { 144 | for _, selection := range selectionSet { 145 | indentPrefix(sb, level) 146 | switch selection := selection.(type) { 147 | case *ast.Field: 148 | if selection.Alias != selection.Name { 149 | sb.WriteString(selection.Alias) 150 | sb.WriteString(": ") 151 | sb.WriteString(selection.Name) 152 | } else { 153 | sb.WriteString(selection.Alias) 154 | } 155 | formatArgumentList(sb, schema, vars, selection.Arguments) 156 | for _, d := range selection.Directives { 157 | sb.WriteString(" @") 158 | sb.WriteString(d.Name) 159 | formatArgumentList(sb, schema, vars, d.Arguments) 160 | } 161 | if len(selection.SelectionSet) > 0 { 162 | formatSelectionSelectionSet(sb, schema, vars, level, selection.SelectionSet) 163 | } 164 | case *ast.InlineFragment: 165 | fmt.Fprintf(sb, "... on %v", selection.TypeCondition) 166 | formatSelectionSelectionSet(sb, schema, vars, level, selection.SelectionSet) 167 | case *ast.FragmentSpread: 168 | sb.WriteString("...") 169 | sb.WriteString(selection.Name) 170 | } 171 | } 172 | } 173 | 174 | func formatArgumentList(sb *strings.Builder, schema *ast.Schema, vars map[string]interface{}, args ast.ArgumentList) { 175 | if len(args) > 0 { 176 | sb.WriteString("(") 177 | for i, arg := range args { 178 | if i != 0 { 179 | sb.WriteString(", ") 180 | } 181 | fmt.Fprintf(sb, "%s: %s", arg.Name, formatArgument(schema, arg.Value, vars)) 182 | } 183 | sb.WriteString(")") 184 | } 185 | } 186 | 187 | func formatSelectionSet(ctx context.Context, schema *ast.Schema, selection ast.SelectionSet) string { 188 | vars := map[string]interface{}{} 189 | if reqctx := graphql.GetOperationContext(ctx); reqctx != nil { 190 | vars = reqctx.Variables 191 | } 192 | 193 | sb := strings.Builder{} 194 | 195 | sb.WriteString("{") 196 | formatSelection(&sb, schema, vars, 0, selection) 197 | sb.WriteString("\n}") 198 | 199 | return sb.String() 200 | } 201 | 202 | var multipleSpacesRegex = regexp.MustCompile(`\s+`) 203 | 204 | func formatSelectionSetSingleLine(ctx context.Context, schema *ast.Schema, selection ast.SelectionSet) string { 205 | return multipleSpacesRegex.ReplaceAllString(formatSelectionSet(ctx, schema, selection), " ") 206 | } 207 | 208 | func formatArgument(schema *ast.Schema, v *ast.Value, vars map[string]interface{}) string { 209 | if schema == nil { 210 | // this is to allow tests to pass to due the MarshalJSON comparator not having access 211 | // to the schema 212 | return v.String() 213 | } 214 | 215 | // this is a mix between v.String() and v.Raw(vars) as we need a string value with variables replaced 216 | 217 | if v == nil { 218 | return "" 219 | } 220 | switch v.Kind { 221 | case ast.Variable: 222 | return "$" + v.Raw 223 | case ast.IntValue, ast.FloatValue, ast.EnumValue, ast.BooleanValue, ast.NullValue: 224 | return v.Raw 225 | case ast.StringValue, ast.BlockValue: 226 | return strconv.Quote(v.Raw) 227 | case ast.ListValue: 228 | var val []string 229 | for _, elem := range v.Children { 230 | val = append(val, formatArgument(schema, elem.Value, vars)) 231 | } 232 | return "[" + strings.Join(val, ",") + "]" 233 | case ast.ObjectValue: 234 | var val []string 235 | for _, elem := range v.Children { 236 | val = append(val, elem.Name+":"+formatArgument(schema, elem.Value, vars)) 237 | } 238 | return "{" + strings.Join(val, ",") + "}" 239 | default: 240 | panic(fmt.Errorf("unknown value kind %d", v.Kind)) 241 | } 242 | } 243 | 244 | func formatSchema(schema *ast.Schema) string { 245 | buf := bytes.NewBufferString("") 246 | f := formatter.NewFormatter(buf) 247 | f.FormatSchema(schema) 248 | return buf.String() 249 | } 250 | -------------------------------------------------------------------------------- /plugins/auth_jwt_test.go: -------------------------------------------------------------------------------- 1 | package plugins 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/http" 7 | "net/http/httptest" 8 | "strings" 9 | "testing" 10 | "time" 11 | 12 | "github.com/go-jose/go-jose/v4" 13 | "github.com/golang-jwt/jwt/v4" 14 | "github.com/movio/bramble" 15 | "github.com/stretchr/testify/assert" 16 | "github.com/stretchr/testify/require" 17 | ) 18 | 19 | const testPrivateKey = `-----BEGIN RSA PRIVATE KEY----- 20 | MIIEpgIBAAKCAQEAwku141JPd9mYwYBCTygPvuIko2QDiDUj0sDaHRGwWxspGKsn 21 | wisEkVlL6R9m7I1G43jbgp3VaQLZRmNB+WlNhXVVWm4JbwCFWSdvE9aBkjEVfucI 22 | d3U5/dmLpOmtsi+IRcGIN960ks1yoJqo6pkfli+r9xLyProbIA5N0zpEegYfilpQ 23 | /bqIsxGcmcSqkzzT7u3/lZDVbn0+3Tkq0FZ0p0iyWSAWba0DzesGGzUwknJJZ+Lw 24 | 6aNRSBqgvmVia38YTyOCRxcaaTFHahc3hyNN8X4GIhO2wN6EFuConznR71X+zh6Q 25 | 2jj/Ci0OUzcBA/vAmxo81qq/hw+micF2xR3MQQIDAQABAoIBAQCb4qyjHvX9XYrO 26 | rT4GTkkbyErHAMZIsQH15J7atcd9wTPew+uZQHRgvXlHJ9enMM5QUTYk/Mctgoia 27 | jaZwGkmFKxd4/1H4Sj2yww2+p9q7VUA+2dQUK+yEO9drT8T5cmNuPBEzai4MnmM6 28 | cfvWhVYvZD4fdIcBRsXemTtdnqE0GHFNM3bgAF26fqJDYZceR/aAfGMP+jYA1tXa 29 | 2lkCnQyy7W9Go/LjN0XqjeCzcN3HWSCE4ONmmPEUOm4fMrB8tdjoFDOKWsviOQXl 30 | /ixAUqic5E1c2/ff3G+iJ1C98bLpdmc6TwOomczelQ+YqFfBARwexu38a2AgTOZ0 31 | NRdw+6nxAoGBAOnXLkpk2GB2lOt3pGWKTxHB/mms6UUlMh9078ZyoSPYANyVggUC 32 | H81DUPkCL2R3tu9lxiAN5Fj7qXB7KYxqZMsL23jF3hGi1RbK0L00X6+fpUXBkv+b 33 | DdGAAM7bgpNo0Ww1sTh1VX1Kf5rdNvic9E46BqAb72xo4uoraKHkJ3WtAoGBANS1 34 | Ms/1aBiJFFGArS+vtHcGeU7ffYhL6x+iAj3p1Vrb11Vd8ZjvIvkUflUg2AaYfsG0 35 | yoTrUvb399SDofaAM+1ylIBnqltCiUX3ZKcX338ujJbo3jW/sXPw8fosjkemCUFg 36 | pT9j86Et12sMmm+kHzcPTVJjSxqVl/R+gIDV07tlAoGBAKVl2U0vhUi9t1nRt0tH 37 | B+RkleIDNr/8rjZHzO1N2SJ0Py/G5D9MoFfcfGKUpBbpAlDUaM31ZYV3BAMWam3y 38 | NzbTPTpwokFRLm2/qOObLu8W+ZycbbAz6RM8+dVWuEYxxqdGVwK7I2vKjPVp8N7q 39 | jXbjXhpTiAbjLVU6vPh9W1fFAoGBAIyPoSBjn4J3M4IYclnM1ojBMnC4p4/l+15Q 40 | BQM8/syn8khraDgT7xyCOmmu5pKVO05uVlY32/9wJcm9os3uMmJ7ET85Qg5Ejco6 41 | jb0NvZeh/y3KfO0v2+guFPmpb+xRAFS/tPOK7XhZfr0y+utDnY0ZA5OqIftTV7Mt 42 | 1WVN6DkxAoGBANo/rsM9en4BYRu8KFGXlY1niUwKZuJYdk31g12xxVpj8PLU92Pr 43 | OFbJL6nupeyfOcEGKy9pyQ+aeIC9ZSHKFQKDLh8Gg8/MopsBGcUBPE98FRparvrX 44 | rq/PmqBaJYfz1JapR/Qt9ecFxwSjqZtfjWaBBhvkYDU1FfOHmZPWDxJi 45 | -----END RSA PRIVATE KEY-----` 46 | 47 | const testPublicKey = `-----BEGIN PUBLIC KEY----- 48 | MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwku141JPd9mYwYBCTygP 49 | vuIko2QDiDUj0sDaHRGwWxspGKsnwisEkVlL6R9m7I1G43jbgp3VaQLZRmNB+WlN 50 | hXVVWm4JbwCFWSdvE9aBkjEVfucId3U5/dmLpOmtsi+IRcGIN960ks1yoJqo6pkf 51 | li+r9xLyProbIA5N0zpEegYfilpQ/bqIsxGcmcSqkzzT7u3/lZDVbn0+3Tkq0FZ0 52 | p0iyWSAWba0DzesGGzUwknJJZ+Lw6aNRSBqgvmVia38YTyOCRxcaaTFHahc3hyNN 53 | 8X4GIhO2wN6EFuConznR71X+zh6Q2jj/Ci0OUzcBA/vAmxo81qq/hw+micF2xR3M 54 | QQIDAQAB 55 | -----END PUBLIC KEY-----` 56 | 57 | func TestJWTPlugin(t *testing.T) { 58 | manualKeyProvider, err := NewManualSigningKeysProvider(map[string]string{"": testPublicKey}) 59 | require.NoError(t, err) 60 | keyProviders := []SigningKeyProvider{manualKeyProvider} 61 | t.Run("valid token", func(t *testing.T) { 62 | basicRole := bramble.OperationPermissions{ 63 | AllowedRootQueryFields: bramble.AllowedFields{ 64 | AllowAll: true, 65 | }, 66 | } 67 | privateKey, err := jwt.ParseRSAPrivateKeyFromPEM([]byte(testPrivateKey)) 68 | require.NoError(t, err) 69 | 70 | token, err := jwt.NewWithClaims(jwt.SigningMethodRS256, &Claims{ 71 | Role: "basic_role", 72 | RegisteredClaims: jwt.RegisteredClaims{ 73 | Audience: jwt.ClaimStrings{"test-audience"}, 74 | ID: "test-id", 75 | Issuer: "test-issuer", 76 | Subject: "test-subject", 77 | }, 78 | }).SignedString(privateKey) 79 | require.NoError(t, err) 80 | 81 | mockHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 82 | role, ok := bramble.GetPermissionsFromContext(r.Context()) 83 | assert.True(t, ok) 84 | assert.Equal(t, basicRole, role) 85 | w.WriteHeader(http.StatusTeapot) 86 | }) 87 | 88 | jwtPlugin := NewJWTPlugin(keyProviders, map[string]bramble.OperationPermissions{ 89 | "basic_role": basicRole, 90 | }) 91 | 92 | handler := jwtPlugin.ApplyMiddlewarePublicMux(mockHandler) 93 | req := httptest.NewRequest(http.MethodPost, "/query", strings.NewReader("{}")) 94 | req.Header.Add("authorization", "Bearer "+token) 95 | rr := httptest.NewRecorder() 96 | 97 | handler.ServeHTTP(rr, req) 98 | 99 | assert.Equal(t, http.StatusTeapot, rr.Result().StatusCode) 100 | }) 101 | 102 | t.Run("expired token", func(t *testing.T) { 103 | privateKey, err := jwt.ParseRSAPrivateKeyFromPEM([]byte(testPrivateKey)) 104 | require.NoError(t, err) 105 | 106 | token, err := jwt.NewWithClaims(jwt.SigningMethodRS256, &Claims{ 107 | RegisteredClaims: jwt.RegisteredClaims{ 108 | ExpiresAt: jwt.NewNumericDate(time.Now().Add(-1 * time.Second)), 109 | }, 110 | Role: "basic_role", 111 | }).SignedString(privateKey) 112 | require.NoError(t, err) 113 | 114 | mockHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 115 | w.WriteHeader(http.StatusTeapot) 116 | }) 117 | 118 | jwtPlugin := NewJWTPlugin(keyProviders, nil) 119 | 120 | handler := jwtPlugin.ApplyMiddlewarePublicMux(mockHandler) 121 | req := httptest.NewRequest(http.MethodPost, "/query", strings.NewReader("{}")) 122 | req.Header.Add("authorization", "Bearer "+token) 123 | rr := httptest.NewRecorder() 124 | 125 | handler.ServeHTTP(rr, req) 126 | 127 | assert.Equal(t, http.StatusUnauthorized, rr.Result().StatusCode) 128 | }) 129 | 130 | t.Run("invalid kid", func(t *testing.T) { 131 | privateKey, err := jwt.ParseRSAPrivateKeyFromPEM([]byte(testPrivateKey)) 132 | require.NoError(t, err) 133 | 134 | token := jwt.NewWithClaims(jwt.SigningMethodRS256, &Claims{ 135 | Role: "basic_role", 136 | }) 137 | token.Header["kid"] = "invalid_kid" 138 | 139 | tokenStr, err := token.SignedString(privateKey) 140 | require.NoError(t, err) 141 | 142 | mockHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 143 | w.WriteHeader(http.StatusTeapot) 144 | }) 145 | 146 | jwtPlugin := NewJWTPlugin(keyProviders, nil) 147 | 148 | handler := jwtPlugin.ApplyMiddlewarePublicMux(mockHandler) 149 | req := httptest.NewRequest(http.MethodPost, "/query", strings.NewReader("{}")) 150 | req.Header.Add("authorization", "Bearer "+tokenStr) 151 | rr := httptest.NewRecorder() 152 | 153 | handler.ServeHTTP(rr, req) 154 | 155 | assert.Equal(t, http.StatusUnauthorized, rr.Result().StatusCode) 156 | }) 157 | 158 | t.Run("JWKS", func(t *testing.T) { 159 | privateKey, err := jwt.ParseRSAPrivateKeyFromPEM([]byte(testPrivateKey)) 160 | require.NoError(t, err) 161 | 162 | jwksHandler := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 163 | var jwks jose.JSONWebKeySet 164 | jwks.Keys = append(jwks.Keys, jose.JSONWebKey{ 165 | Key: &privateKey.PublicKey, 166 | KeyID: "test-key-id", 167 | Algorithm: string(jose.RS256), 168 | }) 169 | _ = json.NewEncoder(w).Encode(jwks) 170 | })) 171 | 172 | jwtPlugin := NewJWTPlugin(nil, nil) 173 | err = jwtPlugin.Configure(&bramble.Config{}, json.RawMessage(fmt.Sprintf(`{ 174 | "jwks": [%q], 175 | "roles": { 176 | "basic_role": { 177 | "query": "*" 178 | } 179 | } 180 | }`, jwksHandler.URL))) 181 | require.NoError(t, err) 182 | 183 | token := jwt.NewWithClaims(jwt.SigningMethodRS256, &Claims{ 184 | Role: "basic_role", 185 | RegisteredClaims: jwt.RegisteredClaims{ 186 | Audience: jwt.ClaimStrings{"test-audience"}, 187 | ID: "test-id", 188 | Issuer: "test-issuer", 189 | Subject: "test-subject", 190 | }, 191 | }) 192 | token.Header["kid"] = "test-key-id" 193 | tokenStr, err := token.SignedString(privateKey) 194 | require.NoError(t, err) 195 | 196 | mockHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 197 | w.WriteHeader(http.StatusTeapot) 198 | }) 199 | 200 | handler := jwtPlugin.ApplyMiddlewarePublicMux(mockHandler) 201 | req := httptest.NewRequest(http.MethodPost, "/query", strings.NewReader("{}")) 202 | req.Header.Add("authorization", "Bearer "+tokenStr) 203 | rr := httptest.NewRecorder() 204 | 205 | handler.ServeHTTP(rr, req) 206 | 207 | assert.Equal(t, http.StatusTeapot, rr.Result().StatusCode) 208 | }) 209 | } 210 | -------------------------------------------------------------------------------- /plugins/auth_jwt.go: -------------------------------------------------------------------------------- 1 | package plugins 2 | 3 | import ( 4 | "context" 5 | "crypto/rsa" 6 | "encoding/json" 7 | "fmt" 8 | "io" 9 | log "log/slog" 10 | "net/http" 11 | "os" 12 | "strings" 13 | 14 | "github.com/go-jose/go-jose/v4" 15 | "github.com/golang-jwt/jwt/v4" 16 | "github.com/golang-jwt/jwt/v4/request" 17 | "github.com/movio/bramble" 18 | ) 19 | 20 | func init() { 21 | bramble.RegisterPlugin(NewJWTPlugin(nil, nil)) 22 | } 23 | 24 | func NewJWTPlugin(keyProviders []SigningKeyProvider, roles map[string]bramble.OperationPermissions) *JWTPlugin { 25 | publicKeys := make(map[string]*rsa.PublicKey) 26 | for _, p := range keyProviders { 27 | keys, err := p.Keys() 28 | if err != nil { 29 | log.With( 30 | "error", err, 31 | "provider", p.Name(), 32 | ).Warn("failed to get signing keys for provider") 33 | os.Exit(1) 34 | } 35 | for id, k := range keys { 36 | publicKeys[id] = k 37 | } 38 | } 39 | 40 | return &JWTPlugin{ 41 | publicKeys: publicKeys, 42 | config: JWTPluginConfig{ 43 | Roles: roles, 44 | }, 45 | jwtExtractor: request.MultiExtractor{ 46 | request.AuthorizationHeaderExtractor, 47 | cookieTokenExtractor{cookieName: "token"}, 48 | }, 49 | } 50 | } 51 | 52 | // JWTPlugin validates that requests contains a valid JWT access token and add 53 | // the necessary permissions and information to the context 54 | type JWTPlugin struct { 55 | config JWTPluginConfig 56 | keyProviders []SigningKeyProvider 57 | publicKeys map[string]*rsa.PublicKey 58 | jwtExtractor request.Extractor 59 | 60 | bramble.BasePlugin 61 | } 62 | 63 | type JWTPluginConfig struct { 64 | // List of JWKS endpoints 65 | JWKS []WellKnownKeyProvider `json:"jwks"` 66 | // Map of kid -> public key (RSA, PEM format) 67 | PublicKeys map[string]string `json:"public-keys"` 68 | Roles map[string]bramble.OperationPermissions `json:"roles"` 69 | } 70 | 71 | type SigningKeyProvider interface { 72 | Name() string 73 | Keys() (map[string]*rsa.PublicKey, error) 74 | } 75 | 76 | func (p *JWTPlugin) ID() string { 77 | return "auth-jwt" 78 | } 79 | 80 | func (p *JWTPlugin) Configure(cfg *bramble.Config, data json.RawMessage) error { 81 | err := json.Unmarshal(data, &p.config) 82 | if err != nil { 83 | return err 84 | } 85 | 86 | for _, k := range p.config.JWKS { 87 | p.keyProviders = append(p.keyProviders, &k) 88 | } 89 | 90 | if len(p.config.PublicKeys) > 0 { 91 | provider, err := NewManualSigningKeysProvider(p.config.PublicKeys) 92 | if err != nil { 93 | return fmt.Errorf("error creating manual keys provider: %w", err) 94 | } 95 | p.keyProviders = append(p.keyProviders, provider) 96 | } 97 | 98 | p.publicKeys = make(map[string]*rsa.PublicKey) 99 | for _, kp := range p.keyProviders { 100 | keys, err := kp.Keys() 101 | if err != nil { 102 | return fmt.Errorf("couldn't get signing keys for provider %q: %w", kp.Name(), err) 103 | } 104 | for id, k := range keys { 105 | p.publicKeys[id] = k 106 | } 107 | } 108 | 109 | return nil 110 | } 111 | 112 | type Claims struct { 113 | jwt.RegisteredClaims 114 | Role string 115 | } 116 | 117 | func (p *JWTPlugin) ApplyMiddlewarePublicMux(h http.Handler) http.Handler { 118 | return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { 119 | tokenStr, err := p.jwtExtractor.ExtractToken(r) 120 | if err != nil { 121 | // unauthenticated request, must use "public_role" 122 | log.Info("unauthenticated request") 123 | r = r.WithContext(bramble.AddPermissionsToContext(r.Context(), p.config.Roles["public_role"])) 124 | h.ServeHTTP(rw, r) 125 | return 126 | } 127 | 128 | var claims Claims 129 | _, err = jwt.ParseWithClaims(tokenStr, &claims, func(token *jwt.Token) (interface{}, error) { 130 | if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok { 131 | return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) 132 | } 133 | 134 | keyID, _ := token.Header["kid"].(string) 135 | if key, ok := p.publicKeys[keyID]; ok { 136 | return key, nil 137 | } 138 | 139 | return nil, fmt.Errorf("could not find key for kid %q", keyID) 140 | }) 141 | if err != nil { 142 | log.With("error", err).Info("invalid token") 143 | rw.WriteHeader(http.StatusUnauthorized) 144 | writeGraphqlError(rw, "invalid token") 145 | return 146 | } 147 | 148 | role, ok := p.config.Roles[claims.Role] 149 | if !ok { 150 | log.With("role", claims.Role).Info("invalid role") 151 | rw.WriteHeader(http.StatusUnauthorized) 152 | writeGraphqlError(rw, "invalid role") 153 | return 154 | } 155 | 156 | bramble.AddFields(r.Context(), bramble.EventFields{ 157 | "role": claims.Role, 158 | "subject": claims.Subject, 159 | }) 160 | 161 | ctx := r.Context() 162 | ctx = bramble.AddPermissionsToContext(ctx, role) 163 | ctx = addStandardJWTClaimsToOutgoingRequest(ctx, claims.RegisteredClaims) 164 | ctx = bramble.AddOutgoingRequestsHeaderToContext(ctx, "JWT-Claim-Role", claims.Role) 165 | h.ServeHTTP(rw, r.WithContext(ctx)) 166 | }) 167 | } 168 | 169 | func addStandardJWTClaimsToOutgoingRequest(ctx context.Context, claims jwt.RegisteredClaims) context.Context { 170 | if len(claims.Audience) > 0 { 171 | ctx = bramble.AddOutgoingRequestsHeaderToContext(ctx, "JWT-Claim-Audience", strings.Join(claims.Audience, ",")) 172 | } 173 | if claims.ID != "" { 174 | ctx = bramble.AddOutgoingRequestsHeaderToContext(ctx, "JWT-Claim-ID", claims.ID) 175 | } 176 | if claims.Issuer != "" { 177 | ctx = bramble.AddOutgoingRequestsHeaderToContext(ctx, "JWT-Claim-Issuer", claims.Issuer) 178 | } 179 | if claims.Subject != "" { 180 | ctx = bramble.AddOutgoingRequestsHeaderToContext(ctx, "JWT-Claim-Subject", claims.Subject) 181 | } 182 | return ctx 183 | } 184 | 185 | func writeGraphqlError(w io.Writer, message string) { 186 | json.NewEncoder(w).Encode(bramble.Response{Errors: bramble.GraphqlErrors{{Message: message}}}) 187 | } 188 | 189 | // cookieTokenExtractor extracts a JWT token from the "token" cookie 190 | type cookieTokenExtractor struct { 191 | cookieName string 192 | } 193 | 194 | func (c cookieTokenExtractor) ExtractToken(r *http.Request) (string, error) { 195 | cookie, err := r.Cookie(c.cookieName) 196 | if err != nil { 197 | return "", request.ErrNoTokenInRequest 198 | } 199 | return cookie.Value, nil 200 | } 201 | 202 | type ManualSigningKeysProvider struct { 203 | keys map[string]*rsa.PublicKey 204 | } 205 | 206 | func NewManualSigningKeysProvider(keys map[string]string) (*ManualSigningKeysProvider, error) { 207 | parsedKeys := make(map[string]*rsa.PublicKey) 208 | 209 | for kid, key := range keys { 210 | publicKey, err := jwt.ParseRSAPublicKeyFromPEM([]byte(key)) 211 | if err != nil { 212 | return nil, err 213 | } 214 | parsedKeys[kid] = publicKey 215 | } 216 | 217 | return &ManualSigningKeysProvider{ 218 | keys: parsedKeys, 219 | }, nil 220 | } 221 | 222 | func (m *ManualSigningKeysProvider) Name() string { 223 | return "manual" 224 | } 225 | 226 | func (m *ManualSigningKeysProvider) Keys() (map[string]*rsa.PublicKey, error) { 227 | return m.keys, nil 228 | } 229 | 230 | type WellKnownKeyProvider struct { 231 | url string 232 | } 233 | 234 | func NewWellKnownKeyProvider(url string) *WellKnownKeyProvider { 235 | return &WellKnownKeyProvider{ 236 | url: url, 237 | } 238 | } 239 | 240 | func (w *WellKnownKeyProvider) MarshalJSON() ([]byte, error) { 241 | return json.Marshal(w.url) 242 | } 243 | 244 | func (w *WellKnownKeyProvider) UnmarshalJSON(data []byte) error { 245 | return json.Unmarshal(data, &w.url) 246 | } 247 | 248 | func (w *WellKnownKeyProvider) Name() string { 249 | return fmt.Sprintf("well-known, url: %q", w.url) 250 | } 251 | 252 | func (w *WellKnownKeyProvider) Keys() (map[string]*rsa.PublicKey, error) { 253 | resp, err := http.Get(w.url) 254 | if err != nil { 255 | return nil, fmt.Errorf("error requesting URL: %w", err) 256 | } 257 | defer resp.Body.Close() 258 | 259 | var s jose.JSONWebKeySet 260 | err = json.NewDecoder(resp.Body).Decode(&s) 261 | if err != nil { 262 | return nil, fmt.Errorf("error decoding response: %w", err) 263 | } 264 | 265 | res := make(map[string]*rsa.PublicKey) 266 | for _, k := range s.Keys { 267 | rsaKey, ok := k.Key.(*rsa.PublicKey) 268 | if !ok { 269 | continue 270 | } 271 | 272 | res[k.KeyID] = rsaKey 273 | } 274 | 275 | return res, nil 276 | } 277 | --------------------------------------------------------------------------------