diff --git a/pkg/apisix/client.go b/pkg/apisix/client.go new file mode 100644 index 00000000000..adf2cb0fb40 --- /dev/null +++ b/pkg/apisix/client.go @@ -0,0 +1,123 @@ +// Licensed to the Apache Software Foundation (ASF) under one or more +// contributor license agreements. See the NOTICE file distributed with +// this work for additional information regarding copyright ownership. +// The ASF licenses this file to You 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 apisix + +import ( + "context" + "io" + "io/ioutil" + "net/http" + "time" + + "github.com/api7/ingress-controller/pkg/log" + "go.uber.org/zap" + + v1 "github.com/gxthrj/apisix-types/pkg/apis/apisix/v1" +) + +const ( + _defaultTimeout = 5 * time.Second +) + +// Options contains parameters to customize APISIX client. +type Options struct { + AdminKey string + BaseURL string + Timeout time.Duration +} + +// Interface is the unified client tool to communicate with APISIX. +type Client interface { + Route() Route +} + +// Route is the specific client to take over the create, update, list and delete +// for APISIX's Route resource. +type Route interface { + List(context.Context, string) ([]*v1.Route, error) + Create(context.Context, *v1.Route, string) (*v1.Route, error) + Delete(context.Context, *v1.Route) error + Update(context.Context, *v1.Route) error +} + +type client struct { + stub *stub + route Route +} + +type stub struct { + baseURL string + adminKey string + cli *http.Client +} + +func (s *stub) applyAuth(req *http.Request) { + if s.adminKey != "" { + req.Header.Set("X-API-Key", s.adminKey) + } +} + +func (s *stub) do(req *http.Request) (*http.Response, error) { + s.applyAuth(req) + return s.cli.Do(req) +} + +// NewClient creates an APISIX client to perform resources change pushing. +func NewClient(o *Options) Client { + if o.BaseURL == "" { + o.BaseURL = "/apisix/admin" + } + if o.Timeout == time.Duration(0) { + o.Timeout = _defaultTimeout + } + stub := &stub{ + baseURL: o.BaseURL, + adminKey: o.AdminKey, + cli: &http.Client{ + Timeout: o.Timeout, + Transport: &http.Transport{ + ResponseHeaderTimeout: o.Timeout, + ExpectContinueTimeout: o.Timeout, + }, + }, + } + cli := &client{ + stub: stub, + } + cli.route = newRouteClient(stub) + return cli +} + +// Route implements Client interface. +func (c *client) Route() Route { + return c.route +} + +func drainBody(r io.ReadCloser, url string) { + _, err := io.Copy(ioutil.Discard, r) + if err != nil { + log.Warnw("failed to drain body (read)", + zap.String("url", url), + zap.Error(err), + ) + } + + if err := r.Close(); err != nil { + log.Warnw("failed to drain body (close)", + zap.String("url", url), + zap.Error(err), + ) + } +} diff --git a/pkg/apisix/resource.go b/pkg/apisix/resource.go new file mode 100644 index 00000000000..ad289052e97 --- /dev/null +++ b/pkg/apisix/resource.go @@ -0,0 +1,106 @@ +// Licensed to the Apache Software Foundation (ASF) under one or more +// contributor license agreements. See the NOTICE file distributed with +// this work for additional information regarding copyright ownership. +// The ASF licenses this file to You 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 apisix + +import ( + "encoding/json" + "errors" + "fmt" + "strings" + + v1 "github.com/gxthrj/apisix-types/pkg/apis/apisix/v1" +) + +// listRepsonse is the unified LIST response mapping of APISIX. +type listResponse struct { + Count string `json:"count"` + Node node `json:"node"` +} + +type node struct { + Key string `json:"key"` + Items items `json:"nodes"` +} + +type items []item + +// items implements json.Unmarshaler interface. +// lua-cjson doesn't distinguish empty array and table, +// and by default empty array will be encoded as '{}'. +// We have to maintain the compatibility. +func (items *items) UnmarshalJSON(p []byte) error { + if p[0] == '{' { + if len(p) != 2 { + return errors.New("unexpected non-empty object") + } + return nil + } + var data []item + if err := json.Unmarshal(p, &data); err != nil { + return err + } + *items = data + return nil +} + +type item struct { + Key string `json:"key"` + Value json.RawMessage `json:"value"` +} + +type routeItem struct { + UpstreamId *string `json:"upstream_id"` + ServiceId *string `json:"service_id"` + Host *string `json:"host"` + URI *string `json:"uri"` + Desc *string `json:"desc"` + Methods []*string `json:"methods"` + Plugins map[string]interface{} `json:"plugins"` +} + +// route decodes item.value and converts it to v1.Route. +func (i *item) route(group string) (*v1.Route, error) { + list := strings.Split(i.Key, "/") + if len(list) < 1 { + return nil, fmt.Errorf("bad route config key: %s", i.Key) + } + + var route routeItem + if err := json.Unmarshal(i.Value, &route); err != nil { + return nil, err + } + + name := route.Desc + fullName := "unknown" + if name != nil { + fullName = *name + } + if group != "" { + fullName = group + "_" + fullName + } + + return &v1.Route{ + ID: &list[len(list)-1], + Group: &group, + FullName: &fullName, + Name: route.Desc, + Host: route.Host, + Path: route.URI, + Methods: route.Methods, + UpstreamId: route.UpstreamId, + ServiceId: route.ServiceId, + Plugins: (*v1.Plugins)(&route.Plugins), + }, nil +} diff --git a/pkg/apisix/resource_test.go b/pkg/apisix/resource_test.go new file mode 100644 index 00000000000..7a625db0d43 --- /dev/null +++ b/pkg/apisix/resource_test.go @@ -0,0 +1,78 @@ +// Licensed to the Apache Software Foundation (ASF) under one or more +// contributor license agreements. See the NOTICE file distributed with +// this work for additional information regarding copyright ownership. +// The ASF licenses this file to You 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 apisix + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestItemUnmarshalJSON(t *testing.T) { + var items node + emptyData := ` +{ + "key": "test", + "nodes": {} +} +` + err := json.Unmarshal([]byte(emptyData), &items) + assert.Nil(t, err) + + emptyData = ` +{ + "key": "test", + "nodes": {"a": "b", "c": "d"} +} +` + err = json.Unmarshal([]byte(emptyData), &items) + assert.Equal(t, err.Error(), "unexpected non-empty object") + + emptyArray := ` +{ + "key": "test", + "nodes": [] +} +` + err = json.Unmarshal([]byte(emptyArray), &items) + assert.Nil(t, err) +} + +func TestItemConvertRoute(t *testing.T) { + item := &item{ + Key: "/apisix/routes/001", + Value: json.RawMessage(` + { + "upstream_id": "13", + "service_id": "14", + "host": "foo.com", + "uri": "/shop/133/details", + "methods": ["GET", "POST"] + } + `), + } + + r, err := item.route("qa") + assert.Nil(t, err) + assert.Equal(t, *r.UpstreamId, "13") + assert.Equal(t, *r.ServiceId, "14") + assert.Equal(t, *r.Host, "foo.com") + assert.Equal(t, *r.Path, "/shop/133/details") + assert.Equal(t, *r.Methods[0], "GET") + assert.Equal(t, *r.Methods[1], "POST") + assert.Nil(t, r.Name) + assert.Equal(t, *r.FullName, "qa_unknown") +} diff --git a/pkg/apisix/route.go b/pkg/apisix/route.go new file mode 100644 index 00000000000..55b8857574a --- /dev/null +++ b/pkg/apisix/route.go @@ -0,0 +1,184 @@ +// Licensed to the Apache Software Foundation (ASF) under one or more +// contributor license agreements. See the NOTICE file distributed with +// this work for additional information regarding copyright ownership. +// The ASF licenses this file to You 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 apisix + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "path" + + v1 "github.com/gxthrj/apisix-types/pkg/apis/apisix/v1" + "go.uber.org/zap" + + "github.com/api7/ingress-controller/pkg/log" +) + +type routeReqBody struct { + Desc *string `json:"desc,omitempty"` + URI *string `json:"uri,omitempty"` + Host *string `json:"host,omitempty"` + ServiceId *string `json:"service_id,omitempty"` + Plugins *v1.Plugins `json:"plugins,omitempty"` +} + +type routeRespBody struct { + Action string `json:"action"` + Item item `json:"node"` +} + +type routeClient struct { + url string + stub *stub +} + +func newRouteClient(stub *stub) Route { + return &routeClient{ + url: path.Join(stub.baseURL, "/routes"), + stub: stub, + } +} + +func (r *routeClient) List(ctx context.Context, group string) ([]*v1.Route, error) { + log.Infow("try to list routes in APISIX", zap.String("url", r.url)) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, r.url, nil) + if err != nil { + return nil, err + } + resp, err := r.stub.do(req) + if err != nil { + return nil, err + } + defer drainBody(resp.Body, r.url) + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected status code %d", resp.StatusCode) + } + + var ( + routeItems listResponse + items []*v1.Route + ) + dec := json.NewDecoder(resp.Body) + if err := dec.Decode(&routeItems); err != nil { + log.Errorw("failed to decode routeClient response", + zap.String("url", r.url), + zap.Error(err), + ) + return nil, err + } + for i, item := range routeItems.Node.Items { + if route, err := item.route(group); err != nil { + log.Errorw("failed to convert route item", + zap.String("url", r.url), + zap.String("route_key", item.Key), + zap.Error(err)) + + return nil, err + } else { + items = append(items, route) + } + log.Infof("list route #%d, body: %s", i, string(item.Value)) + } + + return items, nil +} + +func (r *routeClient) Create(ctx context.Context, obj *v1.Route, group string) (*v1.Route, error) { + data, err := json.Marshal(routeReqBody{ + Desc: obj.Name, + URI: obj.Path, + Host: obj.Host, + ServiceId: obj.ServiceId, + Plugins: obj.Plugins, + }) + if err != nil { + return nil, err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, r.url, bytes.NewReader(data)) + if err != nil { + return nil, err + } + resp, err := r.stub.do(req) + if err != nil { + return nil, err + } + + defer drainBody(resp.Body, r.url) + + if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected status code %d", resp.StatusCode) + } + + var ( + routeResp routeRespBody + ) + dec := json.NewDecoder(resp.Body) + if err := dec.Decode(&routeResp); err != nil { + return nil, err + } + + return routeResp.Item.route(group) +} + +func (r *routeClient) Delete(ctx context.Context, obj *v1.Route) error { + log.Infof("delete route, id:%s", *obj.ID) + url := path.Join(r.url, *obj.ID) + req, err := http.NewRequestWithContext(ctx, http.MethodDelete, url, nil) + if err != nil { + return err + } + resp, err := r.stub.do(req) + if err != nil { + return err + } + defer drainBody(resp.Body, url) + + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusNotFound { + return fmt.Errorf("unexpected status code %d", resp.StatusCode) + } +} + +func (r *routeClient) Update(ctx context.Context, obj *v1.Route) error { + log.Infof("update route, id:%s", *obj.ID) + body, err := json.Marshal(routeReqBody{ + Desc: obj.Name, + Host: obj.Host, + URI: obj.Path, + ServiceId: obj.ServiceId, + Plugins: obj.Plugins, + }) + if err != nil { + return err + } + url := path.Join(r.url, *obj.ID) + req, err := http.NewRequestWithContext(ctx, http.MethodPatch, url, bytes.NewReader(body)) + if err != nil { + return err + } + resp, err := r.stub.do(req) + if err != nil { + return err + } + defer drainBody(resp.Body, url) + + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { + return fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + return nil +}