diff --git a/activity/graphql/README.md b/activity/graphql/README.md new file mode 100644 index 0000000..b1375ad --- /dev/null +++ b/activity/graphql/README.md @@ -0,0 +1,111 @@ +# GraphQL + +The `graphql` service type accepts GraphQL request and applies policies and validates against the schema. + +The service `settings` and available `input` for the request are as follows: + +The available service `settings` are as follows: + +| Name | Type | Description | +|:-----------|:--------|:--------------| +| mode | setting | mode 'a': validate request against GraphQL schema and maximum allowed query depth. mode 'b': Throttle requests based on server time | +| limit | setting | Limit specified in the format maxLimit-fillLimit-fillRate in milli seconds. Example: 1000-200-2000 indicates - maximum server time is set to 1000ms and client gains 200ms of server time per 2000ms | + + +The available `input` for the request are as follows: + +| Name | Type | Description | +|:-----------|:--------|:--------------| +| query | input | GraphQL request string | +| schemaFile | input | GraphQL schema file path | +| maxQueryDepth | input | Maximum allowed GraphQL query depth | +| token | string | Token for which rate limit has to be applied | +| operation | string | An operation to perform: `startconsume` - start consuming the server time. `stopconsume` - stop consuming server time | + + +The available response outputs are as follows: + +| Name | Type | Description | +|:-----------|:--------|:--------------| +| valid | boolean | `true` if the GraphQL query is valid | +| error | boolean | `true` if any error occured while inspecting the GraphQL query | +| errorMessage | string | The error message | + +A sample `service` definition is: + +```json +{ + "name": "GraphQL", + "description": "GraphQL policies service", + "ref": "github.com/project-flogo/microgateway/activity/graphql" +} +``` + +An example `step` that invokes `JQL` service using a `GraphQL request` from a HTTP trigger is: + +```json +{ + "service": "GraphQL", + "input": { + "query": "=$.payload.content", + "schemaFile": "schema.graphql", + "maxQueryDepth": 2 + } +} +``` + +Utilizing and extracting the response values can be seen in a conditional evaluation: + +```json +{ + "if": "$.GraphQL.outputs.error == true", + "error": true, + "output": { + "code": 200, + "data": { + "error": "=$.GraphQL.outputs.errorMessage" + } + } +} +``` +## Maximum Query Depth (mode: a) +This mode allows to prevent clients from abusing deep query depth, Knowing your schema might give you an idea of how deep a legitimate query can go. +example bad query: +```sh +query badquery { #depth 0 + author() { #depth 1 + posts { #depth 2 + author { #depth 3 + posts { #depth 4 + author { #depth 5 + } + } + } + } + } +} +``` +gateway configured with `maxQueryDepth` to 3 would consider above query too deep and the query is invalid. + +## Throttle based on server time (mode: b) +This mode allows to set up a maximum server time a client can use over a certain time frame and how much server time is added to the client over time. + +Example: + +```json +{ + "name": "GraphQL", + "description": "GraphQL policies service", + "ref": "github.com/project-flogo/microgateway/activity/graphql", + "settings": { + "mode": "b", + "limit": "1000-200-2000" + } +} +``` +In the above graphql service maximum server time is set to 1000ms and client gains 200ms of server time per 2000ms + + +## TODO +* Policy based on GraphQL query complexity +* Throttling Based on Query Complexity diff --git a/activity/graphql/activity.go b/activity/graphql/activity.go new file mode 100644 index 0000000..6c99590 --- /dev/null +++ b/activity/graphql/activity.go @@ -0,0 +1,303 @@ +package graphql + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "time" + + graphqlgo "github.com/graph-gophers/graphql-go" + "github.com/project-flogo/core/activity" + "github.com/project-flogo/core/data/metadata" + "github.com/project-flogo/microgateway/activity/graphql/ratelimiter" +) + +const ( + // GqlModeA GraphQL policy based on input query depth + GqlModeA = "a" + // GqlModeB GraphQL policy based on utilized server time + GqlModeB = "b" +) + +func init() { + _ = activity.Register(&Activity{}, New) +} + +var activityMd = activity.ToMetadata(&Input{}, &Output{}) + +// New creates new Activity +func New(ctx activity.InitContext) (activity.Activity, error) { + settings := Settings{} + err := metadata.MapToStruct(ctx.Settings(), &settings, true) + if err != nil { + return nil, err + } + + if settings.Mode == "a" { + return &Activity{mode: settings.Mode}, nil + } + + // mode "b": + // validate settings + _, _, _, err = ratelimiter.ParseLimitString(settings.Limit) + if err != nil { + return nil, err + } + + logger := ctx.Logger() + logger.Debugf("Setting: %b", settings) + + rLimiters := make(map[string]*ratelimiter.Limiter, 1) + t1s := make(map[string]time.Time) + + act := &Activity{ + mode: settings.Mode, + limit: settings.Limit, + context: Context{ + rateLimiters: rLimiters, + t1s: t1s, + }, + } + + return act, nil +} + +// Activity is an GraphQLActivity +// inputs : {message} +// outputs: none +type Activity struct { + mode string + limit string + context Context +} + +// Context graphql context +type Context struct { + rateLimiters map[string]*ratelimiter.Limiter + t1s map[string]time.Time +} + +// Metadata returns the activity's metadata +func (a *Activity) Metadata() *activity.Metadata { + return activityMd +} + +// Eval implements api.Activity.Eval - TBD +func (a *Activity) Eval(ctx activity.Context) (done bool, err error) { + fmt.Println("Evaluate graphQL policies") + // get inputs + input := &Input{} + ctx.GetInputObject(input) + // fmt.Println("query: ", input.Query) + // fmt.Println("schema file: ", input.SchemaFile) + + switch a.mode { + case "a": + // START of mode-a + { + // check schema file is provided or not + if input.SchemaFile == "" { + // set error flag & error message in the output + err = ctx.SetOutput("error", true) + if err != nil { + return false, err + } + errMsg := "Schema file is required" + err = ctx.SetOutput("errorMessage", errMsg) + if err != nil { + return false, err + } + + return true, nil + } + + // load schema + schemaStr, err := ioutil.ReadFile(input.SchemaFile) + if err != nil { + fmt.Printf("Not able to read the schema file[%s] with the error - %s \n", input.SchemaFile, err) + // set error flag & error message in the output + err = ctx.SetOutput("error", true) + if err != nil { + return false, err + } + errMsg := fmt.Sprintf("Not able to read the schema file[%s] with the error - %s \n", input.SchemaFile, err) + err = ctx.SetOutput("errorMessage", errMsg) + if err != nil { + return false, err + } + + return true, nil + } + schema, err := graphqlgo.ParseSchema(string(schemaStr), nil) + if err != nil { + fmt.Println("Error while parsing graphql schema: ", err) + // set error flag & error message in the output + err = ctx.SetOutput("error", true) + if err != nil { + return false, err + } + errMsg := fmt.Sprintf("Error while parsing graphql schema: %s", err) + err = ctx.SetOutput("errorMessage", errMsg) + if err != nil { + return false, err + } + + return true, nil + } + + // parse request + var gqlQuery struct { + Query string `json:"query"` + OperationName string `json:"operationName"` + Variables map[string]interface{} `json:"variables"` + } + err = json.Unmarshal([]byte(input.Query), &gqlQuery) + if err != nil { + fmt.Println("Error while parsing graphql query: ", err) + // set error flag & error message in the output + err = ctx.SetOutput("error", true) + if err != nil { + return false, err + } + errMsg := fmt.Sprintf("Not a valid graphQL request. Details: %s", err) + err = ctx.SetOutput("errorMessage", errMsg) + if err != nil { + return false, err + } + + return true, nil + } + + // check query depth + depth := calculateQueryDepth(gqlQuery.Query) + if depth > input.MaxQueryDepth { + // set error flag & error message in the output + err = ctx.SetOutput("error", true) + if err != nil { + return false, err + } + errMsg := fmt.Sprintf("graphQL request query depth[%v] is exceeded allowed maxQueryDepth[%v]", depth, input.MaxQueryDepth) + fmt.Println(errMsg) + err = ctx.SetOutput("errorMessage", errMsg) + if err != nil { + return false, err + } + + return true, nil + } + + // validate request + validationErrors := schema.Validate(gqlQuery.Query) + if validationErrors != nil { + fmt.Printf("Invalid graphql request: %s \n", validationErrors) + + // set error flag & error message in the output + err = ctx.SetOutput("error", true) + if err != nil { + return false, err + } + errMsg := fmt.Sprintf("Not a valid graphQL request. Details: %s", validationErrors) + err = ctx.SetOutput("errorMessage", errMsg) + if err != nil { + return false, err + } + + return true, nil + } + + // set output + err = ctx.SetOutput("valid", true) + if err != nil { + return false, err + } + validationMsg := fmt.Sprintf("Valid graphQL query. query = %s\n type = Query \n queryDepth = %v", input.Query, depth) + err = ctx.SetOutput("validationMessage", validationMsg) + if err != nil { + return false, err + } + + return true, nil + } + // END of mode-a + + case "b": + // START of mode-b + { + var rateLimiterKey string + rateLimiterKey = input.Token + if rateLimiterKey == "" { + rateLimiterKey = "GLOBAL_TOKEN" + } + + switch input.Operation { + case "startconsume": + // get limiter, if not create one + limiter, ok := a.context.rateLimiters[rateLimiterKey] + if !ok { + fmt.Printf("creating a ratelimiter with the limit[%s]\n", a.limit) + limiter = ratelimiter.New(a.limit) + a.context.rateLimiters[rateLimiterKey] = limiter + } + // check available limit + if limiter.AvailableLimit() <= 0 { + err = ctx.SetOutput("error", true) + if err != nil { + return false, err + } + errMsg := "Quota is not available" + fmt.Println(errMsg) + err = ctx.SetOutput("errorMessage", errMsg) + if err != nil { + return false, err + } + } else { + err = ctx.SetOutput("error", false) + if err != nil { + return false, err + } + } + + // start time stamp + a.context.t1s[rateLimiterKey] = time.Now() + + case "stopconsume": + // elapsed time + t1, ok := a.context.t1s[rateLimiterKey] + if !ok { + return false, nil + } + elapsed := time.Since(t1) + elapsedInMs := 1000 * elapsed.Seconds() + elapsedInMsRound := int(elapsedInMs) + fmt.Printf("GraphQL call took %v ms\n", elapsedInMsRound) + + limiter, ok := a.context.rateLimiters[rateLimiterKey] + if !ok { + return false, nil + } + availableLimit, err := limiter.Consume(elapsedInMsRound) + fmt.Printf("available limit = %v \n", availableLimit) + if err != nil { + err = ctx.SetOutput("error", true) + if err != nil { + return false, err + } + errMsg := "Consumed entire Quota" + fmt.Println(errMsg) + err = ctx.SetOutput("errorMessage", errMsg) + if err != nil { + return false, err + } + } + + default: + return true, nil + } + } + // START of mode-b + default: + return true, nil + } + + return true, nil +} diff --git a/activity/graphql/activity_test.go b/activity/graphql/activity_test.go new file mode 100644 index 0000000..b7d0e11 --- /dev/null +++ b/activity/graphql/activity_test.go @@ -0,0 +1,287 @@ +package graphql + +import ( + "testing" + "time" + + "github.com/project-flogo/core/activity" + "github.com/project-flogo/core/data" + "github.com/project-flogo/core/data/mapper" + "github.com/project-flogo/core/data/metadata" + logger "github.com/project-flogo/core/support/log" + "github.com/project-flogo/core/support/trace" + "github.com/stretchr/testify/assert" +) + +type initContext struct { + settings map[string]interface{} +} + +func newInitContext(values map[string]interface{}) *initContext { + if values == nil { + values = make(map[string]interface{}) + } + return &initContext{ + settings: values, + } +} + +func (i *initContext) Settings() map[string]interface{} { + return i.settings +} + +func (i *initContext) MapperFactory() mapper.Factory { + return nil +} + +func (i *initContext) Logger() logger.Logger { + return logger.RootLogger() +} + +type activityContext struct { + input map[string]interface{} + output map[string]interface{} +} + +func newActivityContext(values map[string]interface{}) *activityContext { + if values == nil { + values = make(map[string]interface{}) + } + return &activityContext{ + input: values, + output: make(map[string]interface{}), + } +} + +func (a *activityContext) ActivityHost() activity.Host { + return a +} + +func (a *activityContext) Name() string { + return "test" +} + +func (a *activityContext) GetInput(name string) interface{} { + return a.input[name] +} + +func (a *activityContext) SetOutput(name string, value interface{}) error { + a.output[name] = value + return nil +} + +func (a *activityContext) GetInputObject(input data.StructValue) error { + return input.FromMap(a.input) +} + +func (a *activityContext) SetOutputObject(output data.StructValue) error { + a.output = output.ToMap() + return nil +} + +func (a *activityContext) GetSharedTempData() map[string]interface{} { + return nil +} + +func (a *activityContext) ID() string { + return "test" +} + +func (a *activityContext) IOMetadata() *metadata.IOMetadata { + return nil +} + +func (a *activityContext) Reply(replyData map[string]interface{}, err error) { + +} + +func (a *activityContext) Return(returnData map[string]interface{}, err error) { + +} + +func (a *activityContext) Scope() data.Scope { + return nil +} + +func (a *activityContext) Logger() logger.Logger { + return logger.RootLogger() +} + +func (a *activityContext) GetTracingContext() trace.TracingContext { + return nil +} + +func TestGraphQLModeA(t *testing.T) { + activity, err := New(newInitContext(map[string]interface{}{ + "mode": "a", + })) + assert.Nil(t, err) + + // schemaFile value not set + query := `{"query":"query {stationWithEvaId(evaId: 8000105) { name } }"}` + ctx := newActivityContext(map[string]interface{}{ + "query": query, + "maxQueryDepth": 2, + }) + _, err = activity.Eval(ctx) + assert.True(t, ctx.output["error"].(bool)) + assert.Equal(t, ctx.output["errorMessage"].(string), "Schema file is required") + + // schemaFile value not available + ctx = newActivityContext(map[string]interface{}{ + "query": query, + "schemaFile": "notafile.graphql", + "maxQueryDepth": 2, + }) + _, err = activity.Eval(ctx) + assert.True(t, ctx.output["error"].(bool)) + assert.Contains(t, ctx.output["errorMessage"].(string), "Not able to read the schema file") + + // valid query + ctx = newActivityContext(map[string]interface{}{ + "query": query, + "schemaFile": "examples/schema.graphql", + "maxQueryDepth": 2, + }) + _, err = activity.Eval(ctx) + assert.True(t, ctx.output["valid"].(bool)) + + // invalid query + invalidquery := `{"query":"query {stationWithEvaId(evaId: 8000105) { cityname } }"}` + ctx = newActivityContext(map[string]interface{}{ + "query": invalidquery, + "schemaFile": "examples/schema.graphql", + "maxQueryDepth": 2, + }) + _, err = activity.Eval(ctx) + assert.True(t, ctx.output["error"].(bool)) + + // query depth + querydepth := `{"query":"{stationWithEvaId(evaId: 8000105) {name location { latitude longitude } picture { url } } }"}` + ctx = newActivityContext(map[string]interface{}{ + "query": querydepth, + "schemaFile": "examples/schema.graphql", + "maxQueryDepth": 2, + }) + _, err = activity.Eval(ctx) + assert.True(t, ctx.output["error"].(bool)) +} + +func TestGraphQLModeB(t *testing.T) { + activity, err := New(newInitContext(map[string]interface{}{ + "mode": "b", + "limit": "500-100-400", + })) + assert.Nil(t, err) + + // operations startconsume & stopconsume + query := `{"query":"query {stationWithEvaId(evaId: 8000105) { name } }"}` + ctx := newActivityContext(map[string]interface{}{ + "token": "token1", + "operation": "startconsume", + "query": query, + }) + _, err = activity.Eval(ctx) + assert.False(t, ctx.output["error"].(bool)) + + ctx = newActivityContext(map[string]interface{}{ + "token": "token1", + "operation": "stopconsume", + }) + status, err := activity.Eval(ctx) + assert.True(t, status) + + // consume suring available limit + ctx = newActivityContext(map[string]interface{}{ + "token": "token1", + "operation": "startconsume", + "query": query, + }) + _, err = activity.Eval(ctx) + assert.False(t, ctx.output["error"].(bool)) + time.Sleep(410 * time.Millisecond) + ctx = newActivityContext(map[string]interface{}{ + "token": "token1", + "operation": "stopconsume", + }) + status, err = activity.Eval(ctx) + assert.True(t, status) + + // check for additional quota + time.Sleep(900 * time.Millisecond) + ctx = newActivityContext(map[string]interface{}{ + "token": "token1", + "operation": "startconsume", + "query": query, + }) + _, err = activity.Eval(ctx) + assert.False(t, ctx.output["error"].(bool)) + time.Sleep(100 * time.Millisecond) + ctx = newActivityContext(map[string]interface{}{ + "token": "token1", + "operation": "stopconsume", + }) + status, err = activity.Eval(ctx) + assert.True(t, status) + + // consumed entire quota + ctx = newActivityContext(map[string]interface{}{ + "token": "token1", + "operation": "startconsume", + "query": query, + }) + _, err = activity.Eval(ctx) + assert.False(t, ctx.output["error"].(bool)) + time.Sleep(292 * time.Millisecond) + ctx = newActivityContext(map[string]interface{}{ + "token": "token1", + "operation": "stopconsume", + }) + _, err = activity.Eval(ctx) + assert.True(t, ctx.output["error"].(bool)) + + // using new token + ctx = newActivityContext(map[string]interface{}{ + "token": "token2", + "operation": "startconsume", + "query": query, + }) + _, err = activity.Eval(ctx) + assert.False(t, ctx.output["error"].(bool)) + time.Sleep(100 * time.Millisecond) + ctx = newActivityContext(map[string]interface{}{ + "token": "token2", + "operation": "stopconsume", + }) + status2, err := activity.Eval(ctx) + assert.True(t, status2) + + ctx = newActivityContext(map[string]interface{}{ + "token": "token2", + "operation": "startconsume", + "query": query, + }) + _, err = activity.Eval(ctx) + assert.False(t, ctx.output["error"].(bool)) + time.Sleep(501 * time.Millisecond) + ctx = newActivityContext(map[string]interface{}{ + "token": "token2", + "operation": "stopconsume", + }) + _, err = activity.Eval(ctx) + assert.True(t, ctx.output["error"].(bool)) + + // default global token + ctx = newActivityContext(map[string]interface{}{ + "operation": "startconsume", + "query": query, + }) + _, err = activity.Eval(ctx) + assert.False(t, ctx.output["error"].(bool)) + + ctx = newActivityContext(map[string]interface{}{ + "operation": "stopconsume", + }) + status, err = activity.Eval(ctx) + assert.True(t, status) +} diff --git a/activity/graphql/descriptor.json b/activity/graphql/descriptor.json new file mode 100644 index 0000000..cd67e8e --- /dev/null +++ b/activity/graphql/descriptor.json @@ -0,0 +1,23 @@ +{ + "name": "flogo-gql", + "type": "flogo:activity", + "version": "0.1.0", + "title": "GraphQL", + "description": "Executes GraphQL query", + "homepage": "https://github.com/project-flogo/microgateway/tree/master/activity/graphql", + "input":[ + { + "name": "request", + "type": "string", + "value": "", + "description": "GraphQL request" + } + ], + "output": [ + { + "name": "queryDepth", + "type": "int", + "description": "GraphQL query depth" + } + ] + } \ No newline at end of file diff --git a/activity/graphql/examples/example_test.go b/activity/graphql/examples/example_test.go new file mode 100644 index 0000000..44fdd04 --- /dev/null +++ b/activity/graphql/examples/example_test.go @@ -0,0 +1,163 @@ +package examples + +import ( + "bytes" + "encoding/json" + "io/ioutil" + "net/http" + "path/filepath" + "testing" + + _ "github.com/project-flogo/contrib/activity/rest" + _ "github.com/project-flogo/contrib/trigger/rest" + "github.com/project-flogo/core/engine" + _ "github.com/project-flogo/microgateway" + _ "github.com/project-flogo/microgateway/activity/graphql" + "github.com/project-flogo/microgateway/api" + test "github.com/project-flogo/microgateway/internal/testing" + "github.com/stretchr/testify/assert" +) + +var url = "http://localhost:9096/graphql" + +var validPayload = map[string]string{ + "query": "query {stationWithEvaId(evaId: 8000105){name}}", +} + +var invalidPayload = map[string]string{ + "query": "query {stationWithEvaId(evaId: 8000105) { cityname } }", +} + +var invalidPayload1 = map[string]string{ + "query": "{stationWithEvaId(evaId: 8000105) {name location { latitude longitude } picture { url } } }", +} + +func TestGqlJSON(t *testing.T) { + if testing.Short() { + t.Skip("skipping JSON integration test in short mode") + } + + data, err := ioutil.ReadFile(filepath.FromSlash("./json/flogo.json")) + assert.Nil(t, err) + cfg, err := engine.LoadAppConfig(string(data), false) + assert.Nil(t, err) + e, err := engine.New(cfg) + assert.Nil(t, err) + + defer api.ClearResources() + test.Drain("9096") + err = e.Start() + assert.Nil(t, err) + defer func() { + err := e.Stop() + assert.Nil(t, err) + }() + test.Pour("9096") + + transport := &http.Transport{ + MaxIdleConns: 1, + } + defer transport.CloseIdleConnections() + client := &http.Client{ + Transport: transport, + } + + payload, _ := json.Marshal(&validPayload) + + request := func() string { + req, err := http.NewRequest(http.MethodPost, url, bytes.NewBuffer(payload)) + assert.Nil(t, err) + response, err := client.Do(req) + assert.Nil(t, err) + body, err := ioutil.ReadAll(response.Body) + assert.Nil(t, err) + response.Body.Close() + return string(body) + } + + body := request() + resp := "{\"response\":\"{\\\"data\\\":{\\\"stationWithEvaId\\\":{\\\"name\\\":\\\"Frankfurt (Main) Hbf\\\"}}}\",\"validationMessage\":\"Valid graphQL query. query = {\\\"query\\\":\\\"query {stationWithEvaId(evaId: 8000105){name}}\\\"}\\n type = Query \\n queryDepth = 2\"}\n" + assert.Equal(t, resp, body) + + payload1, _ := json.Marshal(&invalidPayload) + request1 := func() string { + req, err := http.NewRequest(http.MethodPost, url, bytes.NewBuffer(payload1)) + assert.Nil(t, err) + response, err := client.Do(req) + assert.Nil(t, err) + body, err := ioutil.ReadAll(response.Body) + assert.Nil(t, err) + response.Body.Close() + return string(body) + } + + body1 := request1() + resp1 := "{\"error\":\"Not a valid graphQL request. Details: [graphql: Cannot query field \\\"cityname\\\" on type \\\"Station\\\". (line 1, column 43)]\"}\n" + + assert.Equal(t, resp1, body1) + + payload2, _ := json.Marshal(&invalidPayload1) + request2 := func() string { + req, err := http.NewRequest(http.MethodPost, url, bytes.NewBuffer(payload2)) + assert.Nil(t, err) + response, err := client.Do(req) + assert.Nil(t, err) + body, err := ioutil.ReadAll(response.Body) + assert.Nil(t, err) + response.Body.Close() + return string(body) + } + + body2 := request2() + resp2 := "{\"error\":\"graphQL request query depth[3] is exceeded allowed maxQueryDepth[2]\"}\n" + assert.Equal(t, resp2, body2) + +} + +func TestGqlJSONThrottle(t *testing.T) { + if testing.Short() { + t.Skip("skipping JSON integration test in short mode") + } + + data, err := ioutil.ReadFile(filepath.FromSlash("./json-throttle-server-time/flogo.json")) + assert.Nil(t, err) + cfg, err := engine.LoadAppConfig(string(data), false) + assert.Nil(t, err) + e, err := engine.New(cfg) + assert.Nil(t, err) + + defer api.ClearResources() + test.Drain("9096") + err = e.Start() + assert.Nil(t, err) + defer func() { + err := e.Stop() + assert.Nil(t, err) + }() + test.Pour("9096") + + transport := &http.Transport{ + MaxIdleConns: 1, + } + defer transport.CloseIdleConnections() + client := &http.Client{ + Transport: transport, + } + + payload, _ := json.Marshal(&validPayload) + + request := func() string { + req, err := http.NewRequest(http.MethodPost, url, bytes.NewBuffer(payload)) + assert.Nil(t, err) + response, err := client.Do(req) + assert.Nil(t, err) + body, err := ioutil.ReadAll(response.Body) + assert.Nil(t, err) + response.Body.Close() + return string(body) + } + + body := request() + resp := "{\"response\":\"{\\\"data\\\":{\\\"stationWithEvaId\\\":{\\\"name\\\":\\\"Frankfurt (Main) Hbf\\\"}}}\",\"validationMessage\":null}\n" + assert.Equal(t, resp, body) +} diff --git a/activity/graphql/examples/json-throttle-server-time/README.md b/activity/graphql/examples/json-throttle-server-time/README.md new file mode 100644 index 0000000..8910b07 --- /dev/null +++ b/activity/graphql/examples/json-throttle-server-time/README.md @@ -0,0 +1,68 @@ +# Gateway for GraphQL endpoints + +This is a gateway application demonstrates how following polices can be applied for a GraphQL endpoint: +* Throttle GraphQL request based on server time consumed + +## Installation +* Install [Go](https://golang.org/) +* Install the flogo [cli](https://github.com/project-flogo/cli) + +## Setup +``` +git clone https://github.com/project-flogo/microgateway +cd activity/graphql/examples/json-throttle-server-time +``` + +## Testing +Create the gateway: +``` +flogo create -f flogo.json +cd MyProxy +flogo build +``` + +Start the gateway: +``` +bin/MyProxy +``` +and test below scenarios: + +### Validated graphql requests against defined quota + +graphql service is configured with the below limit: +```sh +"settings": { + "mode": "b", + "limit": "1000-200-2000" +} +``` +That means maximum allowed server time is set to 1000ms, client gains 200ms of server time per 2 sec (2000ms). + +* valid request +```sh +query { + stationWithEvaId(evaId: 8000105) { + name + } +} +``` +curl request: +```sh +curl -X POST -H 'Content-Type: application/json' -H 'Token: MY_TOKEN' --data-binary '{"query":"query {stationWithEvaId(evaId: 8000105) { name } }"}' 'localhost:9096/graphql' + +``` +expected response: +```json +{"response":"{\"data\":{\"stationWithEvaId\":{\"name\":\"Frankfurt (Main) Hbf\"}}}","validationMessage":null} +``` + +* Quota limit reached + +Issue multiple requests so that entire quota is consumed. You would see below response: + +```json +{"error":"Consumed entire Quota"} +``` + +### Courtesy +This example uses publicly available `graphql` endpoint [bahnql.herokuapp.com/graphql](https://bahnql.herokuapp.com/graphql) diff --git a/activity/graphql/examples/json-throttle-server-time/flogo.json b/activity/graphql/examples/json-throttle-server-time/flogo.json new file mode 100644 index 0000000..653387b --- /dev/null +++ b/activity/graphql/examples/json-throttle-server-time/flogo.json @@ -0,0 +1,133 @@ +{ + "name": "MyProxy", + "type": "flogo:app", + "version": "1.0.0", + "description": "This is a simple proxy.", + "properties": null, + "channels": null, + "imports":[ + "github.com/project-flogo/contrib/trigger/rest", + "github.com/project-flogo/microgateway/activity/graphql" + ], + "triggers": [ + { + "name": "flogo-rest", + "id": "MyProxy", + "ref": "github.com/project-flogo/contrib/trigger/rest", + "settings": { + "port": "9096" + }, + "handlers": [ + { + "settings": { + "method": "POST", + "path": "/graphql" + }, + "actions": [ + { + "id": "microgateway:Gqlaction" + } + ] + } + ] + } + ], + "resources": [ + { + "id": "microgateway:Gqlaction", + "compressed": false, + "data": { + "name": "Pets", + "steps": [ + { + "service": "GraphQL", + "input": { + "token": "=$.payload.headers.Token", + "operation": "startconsume" + } + }, + { + "if": "$.GraphQL.outputs.error == false", + "service": "GraphQLQuery", + "input": { + "content": "=$.payload.content" + } + }, + { + "if": "$.GraphQL.outputs.error == false", + "service": "GraphQL", + "input": { + "token": "=$.payload.headers.Token", + "operation": "stopconsume" + } + } + ], + "responses": [ + { + "if": "$.GraphQL.outputs.error == true", + "error": true, + "output": { + "code": 200, + "data": { + "error": "=$.GraphQL.outputs.errorMessage" + } + } + }, + { + "if": "$.GraphQLQuery.outputs.status != 200", + "error": true, + "output": { + "code": 403, + "data": { + "error": "Request failed." + } + } + }, + { + "error": false, + "output": { + "code": 200, + "data": { + "response": "=$.GraphQLQuery.outputs.data", + "validationMessage": "=$.GraphQL.outputs.validationMessage" + } + } + } + ], + "services": [ + { + "name": "GraphQLQuery", + "description": "query graphql endpoint", + "ref": "github.com/project-flogo/contrib/activity/rest", + "settings": { + "uri": "https://bahnql.herokuapp.com/graphql", + "method": "POST", + "headers": { + "Accept": "application/json" + } + } + }, + { + "name": "GraphQL", + "description": "GraphQL policies service", + "ref": "github.com/project-flogo/microgateway/activity/graphql", + "settings": { + "mode": "b", + "limit": "1000-200-2000" + } + } + ] + } + } + ], + "actions": [ + { + "ref": "github.com/project-flogo/microgateway", + "settings": { + "uri": "microgateway:Gqlaction" + }, + "id": "microgateway:Gqlaction", + "metadata": null + } + ] + } diff --git a/activity/graphql/examples/json/README.md b/activity/graphql/examples/json/README.md new file mode 100644 index 0000000..35b30d7 --- /dev/null +++ b/activity/graphql/examples/json/README.md @@ -0,0 +1,104 @@ +# Gateway for GraphQL endpoints + +This is a gateway application demonstrates how following polices can be applied for a GraphQL endpoint: +* validate GraphQL request against schema +* configure Maximum Query Depth + +## Installation +* Install [Go](https://golang.org/) +* Install the flogo [cli](https://github.com/project-flogo/cli) + +## Setup +``` +git clone https://github.com/project-flogo/microgateway +cd activity/graphql/examples/json +``` + +## Testing +Create the gateway: +``` +flogo create -f flogo.json +cd MyProxy +flogo build +cd bin +cp ../../../schema.graphql . +``` + +Start the gateway: +``` +./MyProxy +``` +and test below scenarios: + +### Validated graphql request against schema + +* valid request +```sh +query { + stationWithEvaId(evaId: 8000105) { + name + } +} +``` +curl request: +```sh +curl -X POST -H 'Content-Type: application/json' --data-binary '{"query":"query {stationWithEvaId(evaId: 8000105) { name } }"}' 'localhost:9096/graphql' + +``` +expected response: +```json +{ + "response": "{\"data\":{\"stationWithEvaId\":{\"name\":\"Frankfurt (Main) Hbf\"}}}", + "validationMessage": "Valid graphQL query. query = {\"query\":\"query {stationWithEvaId(evaId: 8000105) { name } }\"}\n type = Query \n queryDepth = 2" +} +``` + +* invalid request + +```sh +query { + stationWithEvaId(evaId: 8000105) { + cityname + } +} +``` +curl request: +```sh +curl -X POST -H 'Content-Type: application/json' --data-binary '{"query":"query {stationWithEvaId(evaId: 8000105) { cityname } }"}' 'localhost:9096/graphql' + +``` +expected response: +```json +{"error":"Not a valid graphQL request. Details: [graphql: Cannot query field \"cityname\" on type \"Station\". (line 1, column 43)]"} +``` + +### Validated graphql request against maxQueryDepth + +* request with query depth 3 +```sh +query { #depth 0 + stationWithEvaId(evaId: 8000105) { #depth 1 + name #depth 2 + location { + latitude #depth 3 + longitude + } + picture { + url #depth 3 + } + } +} +``` + +curl request: +```sh +curl -X POST -H 'Content-Type: application/json' --data-binary '{"query":"{stationWithEvaId(evaId: 8000105) {name location { latitude longitude } picture { url } } }"}' 'localhost:9096/graphql' + +``` +expected response: +```json +{"error":"graphQL request query depth[3] is exceeded allowed maxQueryDepth[2]"} +``` + +### Courtesy +This example uses publicly available `graphql` endpoint [bahnql.herokuapp.com/graphql](https://bahnql.herokuapp.com/graphql) diff --git a/activity/graphql/examples/json/flogo.json b/activity/graphql/examples/json/flogo.json new file mode 100644 index 0000000..6951163 --- /dev/null +++ b/activity/graphql/examples/json/flogo.json @@ -0,0 +1,125 @@ +{ + "name": "MyProxy", + "type": "flogo:app", + "version": "1.0.0", + "description": "This is a simple proxy.", + "properties": null, + "channels": null, + "imports":[ + "resta github.com/project-flogo/contrib/trigger/rest", + "graphqla github.com/project-flogo/microgateway/activity/graphql" + ], + "triggers": [ + { + "name": "flogo-rest", + "id": "MyProxy", + "ref": "github.com/project-flogo/contrib/trigger/rest", + "settings": { + "port": "9096" + }, + "handlers": [ + { + "settings": { + "method": "POST", + "path": "/graphql" + }, + "actions": [ + { + "id": "microgateway:Gqlaction" + } + ] + } + ] + } + ], + "resources": [ + { + "id": "microgateway:Gqlaction", + "compressed": false, + "data": { + "name": "Pets", + "steps": [ + { + "service": "GraphQL", + "input": { + "query": "=$.payload.content", + "schemaFile": "schema.graphql", + "maxQueryDepth": 2 + } + }, + { + "if": "$.GraphQL.outputs.valid == true", + "service": "GraphQLQuery", + "input": { + "content": "=$.payload.content" + } + } + ], + "responses": [ + { + "if": "$.GraphQL.outputs.error == true", + "error": true, + "output": { + "code": 200, + "data": { + "error": "=$.GraphQL.outputs.errorMessage" + } + } + }, + { + "if": "$.GraphQLQuery.outputs.status != 200", + "error": true, + "output": { + "code": 403, + "data": { + "error": "Request failed." + } + } + }, + { + "error": false, + "output": { + "code": 200, + "data": { + "response": "=$.GraphQLQuery.outputs.data", + "validationMessage": "=$.GraphQL.outputs.validationMessage" + } + } + } + ], + "services": [ + { + "name": "GraphQLQuery", + "description": "query graphql endpoint", + "ref": "github.com/project-flogo/contrib/activity/rest", + "settings": { + "uri": "https://bahnql.herokuapp.com/graphql", + "method": "POST", + "headers": { + "Accept": "application/json" + } + } + }, + { + "name": "GraphQL", + "description": "GraphQL policies service", + "ref": "github.com/project-flogo/microgateway/activity/graphql", + "settings": { + "mode": "a" + } + } + ] + } + } + ], + "actions": [ + { + "ref": "github.com/project-flogo/microgateway", + "settings": { + "uri": "microgateway:Gqlaction" + }, + "id": "microgateway:Gqlaction", + "metadata": null + } + ] + } diff --git a/activity/graphql/examples/schema.graphql b/activity/graphql/examples/schema.graphql new file mode 100644 index 0000000..9eef582 --- /dev/null +++ b/activity/graphql/examples/schema.graphql @@ -0,0 +1,326 @@ +schema { + query: Query +} +type Query { + stationWithEvaId(evaId: Int!): Station + stationWithStationNumber(stationNumber: Int!): Station + stationWithRill100(rill100: String!): Station + search(searchTerm: String): Searchable! + nearby(latitude: Float!, longitude: Float!, radius: Int = 10000): Nearby! + parkingSpace(id: Int): ParkingSpace + } + type Searchable { + stations: [Station!]! + operationLocations: [OperationLocation!]! + } + type OperationLocation { + id: String + abbrev: String! + name: String! + shortName: String! + type: String! + status: String + locationCode: String + UIC: String! + regionId: String + validFrom: String! + validTill: String + timeTableRelevant: Boolean + borderStation: Boolean + } + type Station { + primaryEvaId: Int + stationNumber: Int + primaryRil100: String + name: String! + location: Location + category: Int! + priceCategory: Int! + hasParking: Boolean! + hasBicycleParking: Boolean! + hasLocalPublicTransport: Boolean! + hasPublicFacilities: Boolean! + hasLockerSystem: Boolean! + hasTaxiRank: Boolean! + hasTravelNecessities: Boolean! + hasSteplessAccess: String! + hasMobilityService: String! + federalState: String! + regionalArea: RegionalArea! + facilities: [Facility!]! + mailingAddress: MailingAddress! + DBInformationOpeningTimes: OpeningTimes + localServiceStaffAvailability: OpeningTimes + aufgabentraeger: StationContact! + timeTableOffice: StationContact + szentrale: StationContact! + stationManagement: StationContact! + timetable: Timetable! + parkingSpaces: [ParkingSpace!]! + hasSteamPermission: Boolean! + hasWiFi: Boolean! + hasTravelCenter: Boolean! + hasRailwayMission: Boolean! + hasDBLounge: Boolean! + hasLostAndFound: Boolean! + hasCarRental: Boolean! + tracks: [Track!]! + picture: Picture + } + type Track { + platform: String! + number: String! + name: String! + # Length of the platform in cm + length: Int + # Height of the platform in cm + height: Int! + } + type Location { + latitude: Float! + longitude: Float! + } + type Facility { + description: String + type: FacilityType! + state: FacilityState! + equipmentNumber: Int + location: Location + } + type Picture { + id: Int! + url: String! + license: String! + photographer: Photographer! + } + type Photographer { + name: String! + url: String! + } + enum FacilityState { + ACTIVE + INACTIVE + UNKNOWN + } + enum FacilityType { + ESCALATOR + ELEVATOR + } + type MailingAddress { + city: String! + zipcode: String! + street: String! + } + type RegionalArea { + number: Int! + name: String! + shortName: String! + } + type OpeningTimes { + monday: OpeningTime + tuesday: OpeningTime + wednesday: OpeningTime + thursday: OpeningTime + friday: OpeningTime + saturday: OpeningTime + sunday: OpeningTime + holiday: OpeningTime + } + type OpeningTime { + from: String! + to: String! + } + type StationContact { + name: String! + shortName: String + email: String + number: String + phoneNumber: String + } + type Nearby { + stations (count: Int = 10, offset: Int = 0): [Station!]! + parkingSpaces (count: Int = 10, offset: Int = 0): [ParkingSpace!]! + travelCenters (count: Int = 10, offset: Int = 0): [TravelCenter!]! + flinksterCars (count: Int = 10, offset: Int = 0): [FlinksterCar!]! + bikes (count: Int = 10, offset: Int = 0): [FlinksterBike!]! + } + type ParkingSpace { + type: String + id: Int! + name: String + label: String + spaceNumber: String + responsibility: String + source: String + nameDisplay: String + spaceType: String + spaceTypeEn: String + spaceTypeName: String + location: Location + url: String + operator: String + operatorUrl: String + address: MailingAddress + distance: String + facilityType: String + facilityTypeEn: String + openingHours: String + openingHoursEn: String + numberParkingPlaces: String + numberHandicapedPlaces: String + isSpecialProductDb: Boolean! + isOutOfService: Boolean! + station: Station + occupancy: Occupancy + outOfServiceText: String + outOfServiceTextEn: String + reservation: String + clearanceWidth: String + clearanceHeight: String + allowedPropulsions: String + hasChargingStation: String + tariffPrices: [ParkingPriceOption!]! + outOfService: Boolean! + isDiscountDbBahnCard: Boolean! + isMonthVendingMachine: Boolean! + isDiscountDbBahnComfort: Boolean! + isDiscountDbParkAndRail: Boolean! + isMonthParkAndRide: Boolean! + isMonthSeason: Boolean! + tariffDiscount: String + tariffFreeParkingTime: String + tariffDiscountEn: String + tariffPaymentOptions: String + tariffPaymentCustomerCards: String + tariffFreeParkingTimeEn: String + tariffPaymentOptionsEn: String + slogan: String + sloganEn: String + occupancy: Occupancy + } + type ParkingPriceOption { + id: Int! + duration: String! + price: Float + } + type Occupancy { + validData: Boolean! + timestamp: String! + timeSegment: String! + category: Int! + text: String! + } + type Timetable { + nextArrivals: [TrainInStation!]! + nextDepatures: [TrainInStation!]! + } + type TrainInStation { + type: String! + trainNumber: String! + platform: String! + time: String! + stops: [String!]! + } + type TravelCenter { + id: Int + name: String + address: MailingAddress + type: String + location: Location + } + type FlinksterCar { + id: String! + name: String! + description: String! + attributes: CarAttributes! + location: Location! + priceOptions: [PriceOption]! + equipment: CarEquipment! + rentalModel: String! + parkingArea: FlinksterParkingArea! + category: String! + url: String! + } + type FlinksterBike { + id: String! + url: String! + name: String! + description: String! + location: Location! + priceOptions: [PriceOption]! + attributes: BikeAttributes! + address: MailingAddress! + rentalModel: String! + type: String! + providerRentalObjectId: Int! + parkingArea: FlinksterParkingArea! + bookingUrl: String! + } + type CarAttributes { + seats: Int! + color: String! + doors: Int! + transmissionType: String! + licensePlate: String + fillLevel: Int + fuel: String + } + type CarEquipment { + cdPlayer: Boolean + airConditioning: Boolean + navigationSystem: Boolean + roofRailing: Boolean + particulateFilter: Boolean + audioInline: Boolean + tyreType: String + bluetoothHandsFreeCalling: Boolean + cruiseControl: Boolean + passengerAirbagTurnOff: Boolean + isofixSeatFittings: Boolean + } + type FlinksterParkingArea { + id: String! + url: String! + name: String! + address: MailingAddress! + parkingDescription: String + accessDescription: String + locationDescription: String + publicTransport: String + provider: FlinksterProvider! + type: String! + position: Location! + GeoJSON: GeoJSON + } + type GeoJSON { + type: String! + features: [GeoFeature!]! + } + type GeoFeature { + type: String! + properties: GeoProperties! + geometry: GeoPolygon! + } + type GeoPolygon { + type: String! + coordinates: [[[[Float]]]]! + } + type GeoProperties { + name: String! + } + type FlinksterProvider { + url: String! + areaId: Int! + networkIds: [Int!]! + } + type BikeAttributes { + licensePlate: String! + } + type PriceOption { + interval: Int + type: String! + grossamount: Float! + currency: String! + taxrate: Float! + preferredprice: Boolean! + } \ No newline at end of file diff --git a/activity/graphql/metadata.go b/activity/graphql/metadata.go new file mode 100644 index 0000000..b55a79c --- /dev/null +++ b/activity/graphql/metadata.go @@ -0,0 +1,82 @@ +package graphql + +import "github.com/project-flogo/core/data/coerce" + +// Settings settings for the GraphQL policy service +type Settings struct { + Mode string `md:"mode,allowed(a,b)"` + Limit string `md:"limit"` +} + +// Input input meta data +type Input struct { + Query string `md:"query"` + SchemaFile string `md:"schemaFile"` + MaxQueryDepth int `md:"maxQueryDepth"` + Token string `md:"token"` + Operation string `md:"operation,allowed(startconsume,stopconsume)"` +} + +func (i *Input) ToMap() map[string]interface{} { + return map[string]interface{}{ + "query": i.Query, + "schemaFile": i.SchemaFile, + "maxQueryDepth": i.MaxQueryDepth, + "token": i.Token, + "operation": i.Operation, + } +} + +func (i *Input) FromMap(values map[string]interface{}) error { + var err error + i.Query, err = coerce.ToString(values["query"]) + if err != nil { + return err + } + i.SchemaFile, err = coerce.ToString(values["schemaFile"]) + if err != nil { + return err + } + i.MaxQueryDepth, err = coerce.ToInt(values["maxQueryDepth"]) + if err != nil { + return err + } + i.Token, err = coerce.ToString(values["token"]) + if err != nil { + return err + } + i.Operation, err = coerce.ToString(values["operation"]) + if err != nil { + return err + } + + return nil +} + +type Output struct { + Valid bool `md:"valid"` + ValidationMessage string `md:"validationMessage"` + Error bool `md:"error"` + ErrorMessage string `md:"errorMessage"` +} + +func (o *Output) FromMap(values map[string]interface{}) error { + valid, err := coerce.ToBool(values["valid"]) + if err != nil { + return err + } + o.Valid = valid + o.ValidationMessage = values["validationMessage"].(string) + o.Error = values["error"].(bool) + o.ErrorMessage = values["errorMessage"].(string) + return nil +} + +func (o *Output) ToMap() map[string]interface{} { + return map[string]interface{}{ + "valid": o.Valid, + "validationMessage": o.ValidationMessage, + "error": o.Error, + "errorMessage": o.ErrorMessage, + } +} diff --git a/activity/graphql/querydepth.go b/activity/graphql/querydepth.go new file mode 100644 index 0000000..c7dcb8d --- /dev/null +++ b/activity/graphql/querydepth.go @@ -0,0 +1,131 @@ +package graphql + +import ( + "fmt" + "math" + + "github.com/graphql-go/graphql/language/ast" + "github.com/graphql-go/graphql/language/kinds" + "github.com/graphql-go/graphql/language/parser" + "github.com/graphql-go/graphql/language/source" +) + +// calculateQueryDepth calculates graphQL request query depth +func calculateQueryDepth(requestString string) int { + depth := 0 + + source := source.NewSource(&source.Source{ + Body: []byte(requestString), + Name: "GraphQL request", + }) + + // parse the source + AST, err := parser.Parse(parser.ParseParams{Source: source}) + if err != nil { + fmt.Println("parse error: ", err) + return -1 + } + + // get queries, mutations & fragments + queriesOrMutations := make(map[string]interface{}) + fragments := make(map[string]interface{}) + for i, node := range AST.Definitions { + if node.GetKind() == kinds.OperationDefinition { + opDef := node.(*ast.OperationDefinition) + if name := opDef.GetName(); name != nil { + queriesOrMutations[name.Value] = node + } else { + queriesOrMutations[string(i)] = node + } + } else if node.GetKind() == kinds.FragmentDefinition { + fDef := node.(*ast.FragmentDefinition) + if name := fDef.GetName(); name != nil { + fragments[name.Value] = node + } else { + fragments[string(i)] = node + } + } + } + depths := make([]int, len(queriesOrMutations)) + + // calculate queryDepth for all queries/mutations + i := 0 + for _, q := range queriesOrMutations { + d, err := calculateDepth(q, fragments, 0, math.MaxInt8) + if err != nil { + fmt.Println("error: ", err) + } + depths[i] = d + i++ + } + + // get max depth from depths + for _, d := range depths { + if depth < d { + depth = d + } + } + + return depth +} + +func calculateDepth(node interface{}, fragments map[string]interface{}, depthSoFar int, maxDepth int) (int, error) { + if depthSoFar > maxDepth { + errMsg := fmt.Sprintf("exceeds maximum allowed depth[%v]", maxDepth) + return -1, fmt.Errorf(errMsg) + } + + switch v := node.(type) { + + case *ast.Document: + depth := 0 + for _, n := range v.Definitions { + d, err := calculateDepth(n, fragments, depthSoFar, maxDepth) + if err != nil { + return -1, err + } + if depth < d { + depth = d + } + } + return depth, nil + + case *ast.OperationDefinition: + return calculateDepth(v.SelectionSet, fragments, depthSoFar, maxDepth) + + case *ast.FragmentDefinition: + return calculateDepth(v.SelectionSet, fragments, depthSoFar, maxDepth) + + case *ast.InlineFragment: + return calculateDepth(v.SelectionSet, fragments, depthSoFar, maxDepth) + + case *ast.SelectionSet: + depth := 0 + for _, n := range v.Selections { + d, err := calculateDepth(n, fragments, depthSoFar, maxDepth) + if err != nil { + return -1, err + } + if depth < d { + depth = d + } + } + return depth, nil + + case *ast.Field: + if v.SelectionSet == nil { + if name := v.Name; name != nil { + fmt.Printf("%s[depth=%v] \n", name.Value, depthSoFar+1) + } + return 1, nil + } + depth, err := calculateDepth(v.GetSelectionSet(), fragments, depthSoFar+1, maxDepth) + return depth + 1, err + + case *ast.FragmentSpread: + return calculateDepth(fragments[v.Name.Value], fragments, depthSoFar, maxDepth) + + default: + return -1, fmt.Errorf("cannot handle node type[%T]", node) + } +} diff --git a/activity/graphql/ratelimiter/limiter.go b/activity/graphql/ratelimiter/limiter.go new file mode 100644 index 0000000..687e5f4 --- /dev/null +++ b/activity/graphql/ratelimiter/limiter.go @@ -0,0 +1,80 @@ +package ratelimiter + +import ( + "fmt" + "strconv" + "strings" + "time" +) + +// Limiter Leaky bucket alogorithm based limiter +type Limiter struct { + limit int +} + +var limiters = make(map[string]*Limiter, 1) + +// ParseLimitString parse limit string into maxLimit, fillLimit & fillRate +func ParseLimitString(limit string) (maxLimit, fillLimit int, fillRate time.Duration, err error) { + tokens := strings.Split(limit, "-") + if len(tokens) != 3 { + err = fmt.Errorf("[%s] is not a valid limit", limit) + return + } + maxLimit, err1 := strconv.Atoi(tokens[0]) + if err1 != nil { + err = fmt.Errorf("[%v] not a valid max limit", tokens[0]) + } + fillLimit, err1 = strconv.Atoi(tokens[1]) + if err1 != nil { + err = fmt.Errorf("[%v] not a valid fill limit", tokens[0]) + } + rate, err1 := strconv.Atoi(tokens[2]) + if err1 != nil { + err = fmt.Errorf("[%v] not a valid fill rate", tokens[2]) + } + fillRate = time.Duration(rate) * time.Millisecond + + return +} + +// New new +func New(limit string) *Limiter { + //parse limit string + maxlimit, fillLimit, fillRate, _ := ParseLimitString(limit) + + lbucket := &Limiter{ + limit: maxlimit, + } + // start fill timer + fillTicker := time.NewTicker(fillRate) + go func() { + for range fillTicker.C { + newLimit := lbucket.limit + fillLimit + if newLimit > maxlimit { + newLimit = maxlimit + } + lbucket.limit = newLimit + } + }() + + return lbucket +} + +// Consume Consume +func (lb *Limiter) Consume(duration int) (int, error) { + if duration > lb.limit { + err := fmt.Errorf("available limit[%v] not sufficient to consume[%v]", lb.limit, duration) + lb.limit = 0 + return 0, err + } + + // consume + lb.limit = lb.limit - duration + return lb.limit, nil +} + +// AvailableLimit AvailableLimit +func (lb *Limiter) AvailableLimit() int { + return lb.limit +} diff --git a/go.mod b/go.mod index c266229..34eb53a 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,8 @@ require ( github.com/golang/protobuf v1.2.0 // indirect github.com/gonum/blas v0.0.0-20180125090452-e7c5890b24cf // indirect github.com/google/flatbuffers v1.10.0 // indirect + github.com/graph-gophers/graphql-go v0.0.0-20191115155744-f33e81362277 + github.com/graphql-go/graphql v0.7.8 github.com/leesper/go_rng v0.0.0-20171009123644-5344a9259b21 // indirect github.com/pkg/errors v0.8.0 // indirect github.com/project-flogo/contrib v0.9.0-alpha.4.0.20190509204259-4246269fb68e @@ -42,3 +44,5 @@ require ( gorgonia.org/vecf32 v0.0.0-20180224100446-da24147133d9 // indirect gorgonia.org/vecf64 v0.0.0-20180224100512-6314d1b6cefc // indirect ) + +go 1.13 diff --git a/go.sum b/go.sum index 8b36d55..3a84fe7 100644 --- a/go.sum +++ b/go.sum @@ -19,12 +19,18 @@ github.com/gonum/blas v0.0.0-20180125090452-e7c5890b24cf h1:ukIp7SJ4RNEkyqdn8EZD github.com/gonum/blas v0.0.0-20180125090452-e7c5890b24cf/go.mod h1:P32wAyui1PQ58Oce/KYkOqQv8cVw1zAapXOl+dRFGbc= github.com/google/flatbuffers v1.10.0 h1:wHCM5N1xsJ3VwePcIpVqnmjAqRXlR44gv4hpGi+/LIw= github.com/google/flatbuffers v1.10.0/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8= +github.com/graph-gophers/graphql-go v0.0.0-20191115155744-f33e81362277 h1:E0whKxgp2ojts0FDgUA8dl62bmH0LxKanMoBr6MDTDM= +github.com/graph-gophers/graphql-go v0.0.0-20191115155744-f33e81362277/go.mod h1:9CQHMSxwO4MprSdzoIEobiHpoLtHm77vfxsvsIN5Vuc= +github.com/graphql-go/graphql v0.7.8 h1:769CR/2JNAhLG9+aa8pfLkKdR0H+r5lsQqling5WwpU= +github.com/graphql-go/graphql v0.7.8/go.mod h1:k6yrAYQaSP59DC5UVxbgxESlmVyojThKdORUqGDGmrI= github.com/julienschmidt/httprouter v1.2.0 h1:TDTW5Yz1mjftljbcKqRcrYhd4XeOoI98t+9HbQbYf7g= github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= github.com/leesper/go_rng v0.0.0-20171009123644-5344a9259b21 h1:O75p5GUdUfhJqNCMM1ntthjtJCOHVa1lzMSfh5Qsa0Y= github.com/leesper/go_rng v0.0.0-20171009123644-5344a9259b21/go.mod h1:N0SVk0uhy+E1PZ3C9ctsPRlvOPAFPkCNlcPBDkt0N3U= github.com/oliveagle/jsonpath v0.0.0-20180606110733-2e52cf6e6852 h1:Yl0tPBa8QPjGmesFh1D0rDy+q1Twx6FyU7VWHi8wZbI= github.com/oliveagle/jsonpath v0.0.0-20180606110733-2e52cf6e6852/go.mod h1:eqOVx5Vwu4gd2mmMZvVZsgIqNSaW3xxRThUJ0k/TPk4= +github.com/opentracing/opentracing-go v1.1.0 h1:pWlfV3Bxv7k65HYwkikxat0+s3pV4bsqf19k25Ur8rU= +github.com/opentracing/opentracing-go v1.1.0/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o= github.com/pkg/errors v0.8.0 h1:WdK/asTD0HN+q6hsWO3/vpuAkAr+tw6aNJNDFFf0+qw= github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=