diff --git a/plugins/wasm-go/extensions/de-graphql/README.md b/plugins/wasm-go/extensions/de-graphql/README.md new file mode 100644 index 000000000..6c0e18712 --- /dev/null +++ b/plugins/wasm-go/extensions/de-graphql/README.md @@ -0,0 +1,202 @@ +# DeGraphQL + +## GraphQL + +### GraphQL 端点 + +REST API 有多个端点,GraphQL API 只有一个端点。 + +```shell +https://api.github.com/graphql +``` +### 与 GraphQL 通信 + +由于 GraphQL 操作由多行 JSON 组成,可以使用 curl 或任何其他采用 HTTP 的库。 + +在 REST 中,HTTP 谓词确定执行的操作。 在 GraphQL 中,执行查询要提供 JSON 请求体,因此 HTTP 谓词为 POST。 唯一的例外是内省查询,它是一种简单的 GET 到终结点查询。 + +### GraphQL POST 请求参数 + +标准的 GraphQL POST 请求情况如下: + +- 添加 HTTP 请求头: Content-Type: application/json +- 使用 JSON 格式的请求体 +- JSON 请求体包含三个字段 + - query:查询文档,必填 + - variables:变量,选填 + - operationName:操作名称,选填,查询文档有多个操作时必填 + +```json +{ + "query": "{viewer{name}}", + "operationName": "", + "variables": { + "name": "value" + } +} +``` + +### GraphQL 基本参数类型 + +- 基本参数类型包含: String, Int, Float, Boolean +- [类型]代表数组,例如:[Int]代表整型数组 +- GraphQL 基本参数传递 + - 小括号内定义形参,注意:参数需要定义类型 + - !(叹号)代表参数不能为空 + +```shell +query ($owner : String!, $name : String!) { + repository(owner: $owner, name: $name) { + name + forkCount + description + } +} +``` + + +### GitHub GraphQL 测试 + +使用 curl 命令查询 GraphQL, 用有效 JSON 请求体发出 POST 请求。 有效请求体必须包含一个名为 query 的字符串。 + +```shell + +curl https://api.github.com/graphql -X POST \ +-H "Authorization: bearer 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" \ +-d "{\"query\": \"query { viewer { login }}\"}" + +{ + "data": { + "viewer": { + "login": "2456868764" + } + } +} +``` + +```shell +curl 'https://api.github.com/graphql' -X POST \ +-H 'Authorization: bearer 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' \ +-d '{"query":"query ($owner: String!, $name: String!) {\n repository(owner: $owner, name: $name) {\n name\n forkCount\n description\n }\n}\n","variables":{"owner":"2456868764","name":"higress"}}' + +{ + "data": { + "repository": { + "name": "higress", + "forkCount": 149, + "description": "Next-generation Cloud Native Gateway | 下一代云原生网关" + } + } +} +``` + + +## DeGraphQL 插件 + +### 参数配置 + +| 参数 | 描述 | 默认 | +|:----------------|:------------------------|:-----------| +| `gql` | graphql 查询 | 不能为空 | +| `endpoint` | graphql 查询端点 | `/graphql` | +| `timeout` | 查询连接超时,单位毫秒 | `5000` | +| `serviceSource` | 服务来源:k8s, nacos,dns, ip | 不能为空 | +| `serviceName` | 服务名称 | 不能为空 | +| `servicePort` | 服务端口 | 不能为空 | +| `namespace` | 服务命名空间, 当服务来源是nacos需要配置 | | +| `domain` | 服务域名,当服务来源是dns配置 | | + +### 插件使用 + +https://github.com/alibaba/higress/issues/268 + +- 测试配置 +```yaml +apiVersion: networking.higress.io/v1 +kind: McpBridge +metadata: + name: default + namespace: higress-system +spec: + registries: + - domain: api.github.com + name: github + port: 443 + type: dns +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + annotations: + higress.io/destination: github.dns + higress.io/upstream-vhost: "api.github.com" + higress.io/backend-protocol: HTTPS + name: github-api + namespace: higress-system +spec: + ingressClassName: higress + rules: + - http: + paths: + - backend: + resource: + apiGroup: networking.higress.io + kind: McpBridge + name: default + path: /api + pathType: Prefix +--- +apiVersion: extensions.higress.io/v1alpha1 +kind: WasmPlugin +metadata: + name: de-graphql-github-api + namespace: higress-system +spec: + matchRules: + - ingress: + - github-api + config: + timeout: 5000 + endpoint: /graphql + serviceSource: dns + serviceName: github + servicePort: 443 + domain: api.github.com + gql: | + query ($owner:String! $name:String!){ + repository(owner:$owner, name:$name) { + name + forkCount + description + } + } + url: oci://higress-registry.cn-hangzhou.cr.aliyuncs.com/plugins/de-graphql:1.0.0 +``` + +- 测试结果 + +```shell +curl "http://localhost/api?owner=alibaba&name=higress" -H "Authorization: Bearer some-token" + +{ + "data": { + "repository": { + "description": "Next-generation Cloud Native Gateway", + "forkCount": 149, + "name": "higress" + } + } +} +``` + +## 参考文档 + +- https://github.com/graphql/graphql-spec +- https://docs.github.com/zh/graphql/guides/forming-calls-with-graphql +- https://github.com/altair-graphql/altair + + + + + + diff --git a/plugins/wasm-go/extensions/de-graphql/VERSION b/plugins/wasm-go/extensions/de-graphql/VERSION new file mode 100644 index 000000000..afaf360d3 --- /dev/null +++ b/plugins/wasm-go/extensions/de-graphql/VERSION @@ -0,0 +1 @@ +1.0.0 \ No newline at end of file diff --git a/plugins/wasm-go/extensions/de-graphql/config/degraphql_config.go b/plugins/wasm-go/extensions/de-graphql/config/degraphql_config.go new file mode 100644 index 000000000..61edc560f --- /dev/null +++ b/plugins/wasm-go/extensions/de-graphql/config/degraphql_config.go @@ -0,0 +1,199 @@ +// Copyright (c) 2022 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package config + +import ( + "errors" + "github.com/alibaba/higress/plugins/wasm-go/pkg/wrapper" + "net/url" + "regexp" + "strings" +) + +const ( + DefaultEndpoint string = "/graphql" + DefaultConnectionTimeout uint32 = 5000 +) + +var gqlVariableRegex = regexp.MustCompile(`\$(\w+)\s*:\s*(String|Float|Int|Boolean)(!?)`) + +type VariableType string + +const ( + StringType VariableType = "String" + IntType VariableType = "Int" + FloatType VariableType = "Float" + BooleanType VariableType = "Boolean" +) + +type Variable struct { + name string + typ VariableType + blank bool + value string +} + +type DeGraphQLConfig struct { + client wrapper.HttpClient + gql string + endpoint string + timeout uint32 + variables []Variable +} + +func (d *DeGraphQLConfig) SetEndpoint(endpoint string) error { + endpoint = strings.TrimSpace(endpoint) + if endpoint == "" { + d.endpoint = DefaultEndpoint + } else { + d.endpoint = endpoint + } + return nil +} + +func (d *DeGraphQLConfig) GetEndpoint() string { + return d.endpoint +} + +func (d *DeGraphQLConfig) GetTimeout() uint32 { + return d.timeout +} + +func (d *DeGraphQLConfig) SetTimeout(timeout uint32) { + if timeout <= 0 { + d.timeout = DefaultConnectionTimeout + } else { + d.timeout = timeout + } +} + +func (d *DeGraphQLConfig) SetClient(client wrapper.HttpClient) { + d.client = client +} + +func (d *DeGraphQLConfig) GetClient() wrapper.HttpClient { + return d.client +} + +func (d *DeGraphQLConfig) SetGql(gql string) error { + if strings.TrimSpace(gql) == "" { + return errors.New("gql can't be empty") + } + d.gql = gql + d.variables = make([]Variable, 0) + matches := gqlVariableRegex.FindAllStringSubmatch(d.gql, -1) + if len(matches) > 0 { + for _, subMatch := range matches { + variable := Variable{} + variable.name = subMatch[1] + switch subMatch[2] { + case "String": + variable.typ = StringType + case "Float": + variable.typ = FloatType + case "Int": + variable.typ = IntType + case "Boolean": + variable.typ = BooleanType + } + variable.blank = subMatch[3] != "!" + d.variables = append(d.variables, variable) + } + + } + return nil +} + +func (d *DeGraphQLConfig) GetGql() string { + return d.gql +} + +func (d *DeGraphQLConfig) GetVersion() string { + return "1.0.0" +} + +func (d *DeGraphQLConfig) ParseGqlFromUrl(requestUrl string) (string, error) { + if strings.TrimSpace(requestUrl) == "" { + return "", errors.New("request url can't be empty") + } + + url, _ := url.Parse(requestUrl) + + queryValues := url.Query() + values := make(map[string]string, len(queryValues)) + for k, v := range queryValues { + var v1 string + if len(v) > 1 { + v1 = strings.Join(v, ",") + } else { + v1 = v[0] + } + values[k] = v1 + } + + variables := make([]Variable, 0, len(d.variables)) + for _, variable := range d.variables { + val, ok := values[variable.name] + // TODO validate variable type and blank + if ok { + variables = append(variables, Variable{ + name: variable.name, + typ: variable.typ, + blank: variable.blank, + value: val, + }) + } + } + + var build strings.Builder + + // write query + build.WriteString("{\"query\":") + build.WriteString("\"") + build.WriteString(getJsonStr(d.gql)) + build.WriteString("\"") + + // write varialbes + if len(variables) > 0 { + index := 0 + build.WriteString(",") + build.WriteString("\"variables\":{") + for _, variable := range variables { + build.WriteString("\"") + build.WriteString(variable.name) + build.WriteString("\":") + if variable.typ == StringType { + build.WriteString("\"") + build.WriteString(getJsonStr(variable.value)) + build.WriteString("\"") + } else { + build.WriteString(variable.value) + } + if index < len(variables)-1 { + build.WriteString(",") + } + index++ + } + build.WriteString("}") + } + + build.WriteString("}") + return build.String(), nil +} + +func getJsonStr(str string) string { + d := strings.ReplaceAll(str, "\"", "\\\"") + return strings.ReplaceAll(d, "\n", "\\n") +} diff --git a/plugins/wasm-go/extensions/de-graphql/config/degraphql_config_test.go b/plugins/wasm-go/extensions/de-graphql/config/degraphql_config_test.go new file mode 100644 index 000000000..813597bdc --- /dev/null +++ b/plugins/wasm-go/extensions/de-graphql/config/degraphql_config_test.go @@ -0,0 +1,177 @@ +// Copyright (c) 2022 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package config + +import ( + "errors" + "github.com/stretchr/testify/assert" + "testing" +) + +func TestDeGraphQLConfig_SetGql(t *testing.T) { + tests := []struct { + name string + gql string + wantVariables []Variable + wantErr error + }{ + { + name: "empty gql", + gql: "", + wantErr: errors.New("gql can't be empty"), + }, + { + name: "no params", + gql: "query", + wantVariables: []Variable{}, + wantErr: nil, + }, + { + name: "four params", + gql: "query ($owner:String $num:Float! $int : Int! $boolean : Boolean )", + wantErr: nil, + wantVariables: []Variable{ + { + name: "owner", + typ: StringType, + blank: true, + }, + { + name: "num", + typ: FloatType, + blank: false, + }, + { + name: "int", + typ: IntType, + blank: false, + }, + { + name: "boolean", + typ: BooleanType, + blank: true, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + d := &DeGraphQLConfig{} + err := d.SetGql(tt.gql) + assert.Equal(t, tt.wantErr, err) + if err != nil { + return + } + assert.Equal(t, tt.wantVariables, d.variables) + }) + } +} + +func TestDeGraphQLConfig_ParseGqlFromUrl(t *testing.T) { + + tests := []struct { + name string + gql string + url string + want string + wantErr error + }{ + { + name: "empty url", + gql: "query ($owner:String! $name:String!)", + url: "", + want: "", + wantErr: errors.New("request url can't be empty"), + }, + + { + name: "no params", + gql: "query HeroNameQuery {\n hero {\n name\n }\n}", + url: "/api?owner=a", + want: "{\"query\":\"query HeroNameQuery {\\n hero {\\n name\\n }\\n}\"}", + wantErr: nil, + }, + + { + name: "one string variable", + gql: "query FetchSomeIDQuery($someId: String!) {\n human(id: $someId) {\n name\n }\n}", + url: "/api?someId=a", + want: "{\"query\":\"query FetchSomeIDQuery($someId: String!) {\\n human(id: $someId) {\\n name\\n }\\n}\",\"variables\":{\"someId\":\"a\"}}", + wantErr: nil, + }, + + { + name: "multi variables", + gql: "query FetchSomeIDQuery($someId: String! $num: Int $price: Float! $need:Boolean!) {\n human(id: $someId) {\n name\n }\n}", + url: "/api?someId=a&num=10&price=12.0&need=false&hee=1", + want: "{\"query\":\"query FetchSomeIDQuery($someId: String! $num: Int $price: Float! $need:Boolean!) {\\n human(id: $someId) {\\n name\\n }\\n}\",\"variables\":{\"someId\":\"a\",\"num\":10,\"price\":12.0,\"need\":false}}", + wantErr: nil, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + d := &DeGraphQLConfig{} + d.SetGql(tt.gql) + body, err := d.ParseGqlFromUrl(tt.url) + assert.Equal(t, tt.wantErr, err) + if err != nil { + return + } + assert.Equal(t, tt.want, body) + }) + } +} + +func TestDeGraphQLConfig_SetEndpoint(t *testing.T) { + + tests := []struct { + name string + endPoint string + wantErr error + want string + }{ + { + name: "empty endpoint", + endPoint: "", + wantErr: nil, + want: "/graphql", + }, + { + name: "empty endpoint with blank", + endPoint: " ", + wantErr: nil, + want: "/graphql", + }, + + { + name: "with value", + endPoint: " /graphql2 ", + wantErr: nil, + want: "/graphql2", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + d := &DeGraphQLConfig{} + err := d.SetEndpoint(tt.endPoint) + assert.Equal(t, tt.wantErr, err) + if err != nil { + return + } + assert.Equal(t, tt.want, d.endpoint) + }) + } +} diff --git a/plugins/wasm-go/extensions/de-graphql/envoy.yaml b/plugins/wasm-go/extensions/de-graphql/envoy.yaml new file mode 100644 index 000000000..70636db53 --- /dev/null +++ b/plugins/wasm-go/extensions/de-graphql/envoy.yaml @@ -0,0 +1,125 @@ +static_resources: + listeners: + - name: main + address: + socket_address: + address: 0.0.0.0 + port_value: 18000 + filter_chains: + - filters: + - name: envoy.http_connection_manager + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager + stat_prefix: ingress_http + codec_type: auto + route_config: + name: local_route + virtual_hosts: + - name: local_service + domains: + - "*" + routes: + - match: + prefix: "/api" + route: + cluster: mock_service + http_filters: + - name: envoy.filters.http.wasm + typed_config: + "@type": type.googleapis.com/udpa.type.v1.TypedStruct + type_url: type.googleapis.com/envoy.extensions.filters.http.wasm.v3.Wasm + value: + config: + configuration: + "@type": type.googleapis.com/google.protobuf.StringValue + value: |- + { + "gql": "query ($owner:String! $name:String!){\n repository(owner:$owner, name:$name) {\n name\n forkCount\n description\n}\n}", + "serviceSource": "dns", + "serviceName": "github", + "servicePort": 443, + "domain": "api.github.com", + "endpoint": "/graphql", + "timeout": 2000 + } + vm_config: + runtime: "envoy.wasm.runtime.v8" + code: + local: + filename: "./main.wasm" + - name: envoy.filters.http.router + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router + + + - name: staticreply + address: + socket_address: + address: 127.0.0.1 + port_value: 8099 + filter_chains: + - filters: + - name: envoy.http_connection_manager + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager + stat_prefix: ingress_http + codec_type: auto + route_config: + name: local_route + virtual_hosts: + - name: local_service + domains: + - "*" + routes: + - match: + prefix: "/" + direct_response: + status: 200 + body: + inline_string: "example body\n" + http_filters: + - name: envoy.filters.http.router + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router + + clusters: + - name: mock_service + connect_timeout: 0.25s + type: STATIC + lb_policy: ROUND_ROBIN + load_assignment: + cluster_name: mock_service + endpoints: + - lb_endpoints: + - endpoint: + address: + socket_address: + address: 127.0.0.1 + port_value: 8099 + - name: outbound|443||github.dns + connect_timeout: 0.5s + type: STRICT_DNS + lb_policy: ROUND_ROBIN + dns_refresh_rate: 5s + dns_lookup_family: V4_ONLY + transport_socket: + name: envoy.transport_sockets.tls + typed_config: + "@type": type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.UpstreamTlsContext + load_assignment: + cluster_name: outbound|443||github.dns + endpoints: + - lb_endpoints: + - endpoint: + address: + socket_address: + address: api.github.com + port_value: 443 + + +admin: + access_log_path: "/dev/null" + address: + socket_address: + address: 0.0.0.0 + port_value: 8001 diff --git a/plugins/wasm-go/extensions/de-graphql/go.mod b/plugins/wasm-go/extensions/de-graphql/go.mod new file mode 100644 index 000000000..e1d357f31 --- /dev/null +++ b/plugins/wasm-go/extensions/de-graphql/go.mod @@ -0,0 +1,20 @@ +module de-graphql + +go 1.19 + +require ( + github.com/alibaba/higress/plugins/wasm-go v0.0.0-20230410091208-df60dd43079c + github.com/stretchr/testify v1.8.0 + github.com/tetratelabs/proxy-wasm-go-sdk v0.22.0 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/google/uuid v1.3.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/tetratelabs/wazero v1.0.0-rc.1 // indirect + github.com/tidwall/gjson v1.14.4 // indirect + github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/pretty v1.2.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/plugins/wasm-go/extensions/de-graphql/go.sum b/plugins/wasm-go/extensions/de-graphql/go.sum new file mode 100644 index 000000000..e94c63239 --- /dev/null +++ b/plugins/wasm-go/extensions/de-graphql/go.sum @@ -0,0 +1,29 @@ +github.com/alibaba/higress/plugins/wasm-go v0.0.0-20230410091208-df60dd43079c h1:W1QzLx6pefqDWi4peW2HKcZY0rgEy11+JCuWtssp1Ew= +github.com/alibaba/higress/plugins/wasm-go v0.0.0-20230410091208-df60dd43079c/go.mod h1:AzSnkuon5c26nIePTiJQIAFsKdhkNdncLcTuahpGtQs= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= +github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/tetratelabs/proxy-wasm-go-sdk v0.22.0 h1:kS7BvMKN+FiptV4pfwiNX8e3q14evxAWkhYbxt8EI1M= +github.com/tetratelabs/proxy-wasm-go-sdk v0.22.0/go.mod h1:qkW5MBz2jch2u8bS59wws65WC+Gtx3x0aPUX5JL7CXI= +github.com/tetratelabs/wazero v1.0.0-rc.1 h1:ytecMV5Ue0BwezjKh/cM5yv1Mo49ep2R2snSsQUyToc= +github.com/tetratelabs/wazero v1.0.0-rc.1/go.mod h1:wYx2gNRg8/WihJfSDxA1TIL8H+GkfLYm+bIfbblu9VQ= +github.com/tidwall/gjson v1.14.4 h1:uo0p8EbA09J7RQaflQ1aBRffTR7xedD2bcIVSYxLnkM= +github.com/tidwall/gjson v1.14.4/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/plugins/wasm-go/extensions/de-graphql/graphql.yaml b/plugins/wasm-go/extensions/de-graphql/graphql.yaml new file mode 100644 index 000000000..bc57713f7 --- /dev/null +++ b/plugins/wasm-go/extensions/de-graphql/graphql.yaml @@ -0,0 +1,61 @@ +apiVersion: networking.higress.io/v1 +kind: McpBridge +metadata: + name: default + namespace: higress-system +spec: + registries: + - domain: api.github.com + name: github + port: 443 + type: dns +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + annotations: + higress.io/destination: github.dns + higress.io/upstream-vhost: "api.github.com" + higress.io/backend-protocol: HTTPS + name: github-api + namespace: higress-system +spec: + ingressClassName: higress + rules: + - http: + paths: + - backend: + resource: + apiGroup: networking.higress.io + kind: McpBridge + name: default + path: /api + pathType: Prefix +--- +apiVersion: extensions.higress.io/v1alpha1 +kind: WasmPlugin +metadata: + name: de-graphql-github-api + namespace: higress-system +spec: + defaultConfigDisable: true + matchRules: + - config: + domain: api.github.com + endpoint: /graphql + gql: |- + query ($owner:String! $name:String!){ + repository(owner:$owner, name:$name) { + name + forkCount + description + } + } + serviceName: github + servicePort: 443 + serviceSource: dns + timeout: 5000 + configDisable: false + ingress: + - github-api + url: oci://docker.io/2456868764/de-graphql:1.0.0 diff --git a/plugins/wasm-go/extensions/de-graphql/main.go b/plugins/wasm-go/extensions/de-graphql/main.go new file mode 100644 index 000000000..f60cb8257 --- /dev/null +++ b/plugins/wasm-go/extensions/de-graphql/main.go @@ -0,0 +1,157 @@ +// Copyright (c) 2022 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "errors" + "fmt" + "net/http" + + "de-graphql/config" + "github.com/alibaba/higress/plugins/wasm-go/pkg/wrapper" + "github.com/tetratelabs/proxy-wasm-go-sdk/proxywasm" + "github.com/tetratelabs/proxy-wasm-go-sdk/proxywasm/types" + "github.com/tidwall/gjson" +) + +func main() { + wrapper.SetCtx( + "de-graphql", + wrapper.ParseConfigBy(parseConfig), + wrapper.ProcessRequestHeadersBy(onHttpRequestHeaders), + wrapper.ProcessRequestBodyBy(onHttpRequestBody), + wrapper.ProcessResponseBodyBy(onHttpResponseBody), + wrapper.ProcessResponseHeadersBy(onHttpResponseHeaders), + ) +} + +func parseConfig(json gjson.Result, config *config.DeGraphQLConfig, log wrapper.Log) error { + log.Debug("parseConfig()") + gql := json.Get("gql").String() + endpoint := json.Get("endpoint").String() + timeout := json.Get("timeout").Int() + log.Debugf("gql:%s endpoint:%s timeout:%d", gql, endpoint, timeout) + err := config.SetGql(gql) + if err != nil { + return err + } + err = config.SetEndpoint(endpoint) + if err != nil { + return err + } + config.SetTimeout(uint32(timeout)) + serviceSource := json.Get("serviceSource").String() + serviceName := json.Get("serviceName").String() + servicePort := json.Get("servicePort").Int() + log.Debugf("serviceSource:%s serviceName:%s servicePort:%d", serviceSource, serviceName, servicePort) + if serviceName == "" || servicePort == 0 { + return errors.New("invalid service config") + } + switch serviceSource { + case "k8s": + namespace := json.Get("namespace").String() + config.SetClient(wrapper.NewClusterClient(wrapper.K8sCluster{ + ServiceName: serviceName, + Namespace: namespace, + Port: servicePort, + })) + return nil + case "nacos": + namespace := json.Get("namespace").String() + config.SetClient(wrapper.NewClusterClient(wrapper.NacosCluster{ + ServiceName: serviceName, + NamespaceID: namespace, + Port: servicePort, + })) + return nil + case "ip": + config.SetClient(wrapper.NewClusterClient(wrapper.StaticIpCluster{ + ServiceName: serviceName, + Port: servicePort, + })) + return nil + case "dns": + domain := json.Get("domain").String() + config.SetClient(wrapper.NewClusterClient(wrapper.DnsCluster{ + ServiceName: serviceName, + Port: servicePort, + Domain: domain, + })) + return nil + default: + return errors.New("unknown service source: " + serviceSource) + } + + return nil +} + +func onHttpRequestHeaders(ctx wrapper.HttpContext, config config.DeGraphQLConfig, log wrapper.Log) types.Action { + log.Debug("onHttpRequestHeaders()") + log.Debugf("schema:%s host:%s path:%s", ctx.Scheme(), ctx.Host(), ctx.Path()) + requestUrl, _ := proxywasm.GetHttpRequestHeader(":path") + method, _ := proxywasm.GetHttpRequestHeader(":method") + log.Debugf("method:%s, request:%s", method, requestUrl) + if err := proxywasm.RemoveHttpRequestHeader("content-length"); err != nil { + log.Debug("can not reset content-length") + } + replaceBody, err := config.ParseGqlFromUrl(requestUrl) + if err != nil { + log.Warnf("failed to parse request url %s : %v", requestUrl, err) + } + log.Debugf("replace body:%s", replaceBody) + + // Pass headers to upstream cluster + headers, _ := proxywasm.GetHttpRequestHeaders() + for i := len(headers) - 1; i >= 0; i-- { + key := headers[i][0] + if key == ":method" || key == ":path" || key == ":authority" { + headers = append(headers[:i], headers[i+1:]...) + } + } + // Add header Content-Type: application/json + headers = append(headers, [2]string{"Content-Type", "application/json"}) + // Call upstream graphql endpoint + config.GetClient().Post(config.GetEndpoint(), headers, []byte(replaceBody), + func(statusCode int, responseHeaders http.Header, responseBody []byte) { + // Pass response headers and body to client + headers := make([][2]string, 0, len(responseHeaders)+3) + for headK, headV := range responseHeaders { + headers = append(headers, [2]string{headK, headV[0]}) + } + // Add debug headers + headers = append(headers, [2]string{"x-degraphql-endpoint", config.GetEndpoint()}) + headers = append(headers, [2]string{"x-degraphql-timeout", fmt.Sprintf("%d", config.GetTimeout())}) + headers = append(headers, [2]string{"x-degraphql-version", config.GetVersion()}) + proxywasm.SendHttpResponse(uint32(statusCode), headers, responseBody, -1) + return + }, config.GetTimeout()) + + return types.ActionPause +} + +func onHttpRequestBody(ctx wrapper.HttpContext, config config.DeGraphQLConfig, body []byte, log wrapper.Log) types.Action { + log.Debug("onHttpRequestBody()") + return types.ActionContinue +} + +func onHttpResponseHeaders(ctx wrapper.HttpContext, config config.DeGraphQLConfig, log wrapper.Log) types.Action { + log.Debug("onHttpResponseHeaders()") + return types.ActionContinue +} + +func onHttpResponseBody(ctx wrapper.HttpContext, config config.DeGraphQLConfig, body []byte, log wrapper.Log) types.Action { + log.Debug("onHttpResponseBody()") + return types.ActionContinue +}