├── 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 |
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 |
2 |
3 | [](https://pkg.go.dev/github.com/movio/bramble)
4 | [](https://goreportcard.com/report/github.com/movio/bramble)
5 | [](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 |
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 |
42 |
--------------------------------------------------------------------------------
/docs/bramble-header.svg:
--------------------------------------------------------------------------------
1 |
2 |
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 |
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 |
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 |
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 |
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 |
--------------------------------------------------------------------------------