From 0e8b1542ebcfdd1221042dc106063161e3a3655a Mon Sep 17 00:00:00 2001 From: Daniel Harper <529730+djhworld@users.noreply.github.com> Date: Tue, 12 Sep 2023 17:09:17 +0100 Subject: [PATCH 1/3] APISHI-2353 Add support for Get/Post/Delete operations in API Shield Endpoint Management --- .changelog/1397.txt | 3 + api_shield_operations.go | 221 ++++++++++++++++ api_shield_operations_test.go | 480 ++++++++++++++++++++++++++++++++++ 3 files changed, 704 insertions(+) create mode 100644 .changelog/1397.txt create mode 100644 api_shield_operations.go create mode 100644 api_shield_operations_test.go diff --git a/.changelog/1397.txt b/.changelog/1397.txt new file mode 100644 index 00000000000..a3954f44735 --- /dev/null +++ b/.changelog/1397.txt @@ -0,0 +1,3 @@ +```release_note:enhancement +api_shield: Add support for Get/Post/Delete operations in API Shield Endpoint Management +``` \ No newline at end of file diff --git a/api_shield_operations.go b/api_shield_operations.go new file mode 100644 index 00000000000..86e9ee2c370 --- /dev/null +++ b/api_shield_operations.go @@ -0,0 +1,221 @@ +package cloudflare + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "strconv" + "strings" + "time" +) + +// APIShieldCreateOperation should be used when creating an operation in API Shield Endpoint Management. +type APIShieldCreateOperation struct { + Method string `json:"method"` + Host string `json:"host"` + Endpoint string `json:"endpoint"` +} + +// APIShieldOperation represents an operation stored in API Shield Endpoint Management. +type APIShieldOperation struct { + APIShieldCreateOperation + ID string `json:"operation_id"` + LastUpdated time.Time `json:"last_updated"` + Features map[string]any `json:"features,omitempty"` +} + +// APIShieldGetOperationParams represents the query parameters to set when retrieving an operation. +// +// See API documentation https://developers.cloudflare.com/api/operations/api-shield-endpoint-management-retrieve-information-about-an-operation +type APIShieldGetOperationParams struct { + // Features represents a set of features to return in `features` object when + // performing making read requests against an Operation or listing operations. + Features []string +} + +// APIShieldGetOperationsParams represents the query parameters to set when retrieving operations +// +// See API documentation https://developers.cloudflare.com/api/operations/api-shield-endpoint-management-retrieve-information-about-all-operations-on-a-zone +type APIShieldGetOperationsParams struct { + // Features represents a set of features to return in `features` object when + // performing making read requests against an Operation or listing operations. + Features []string + // Direction to order results. + Direction string + // OrderBy when requesting a feature, the feature keys are available for ordering as well, e.g., thresholds.suggested_threshold. + OrderBy string + // Filters to only return operations that match filtering criteria, see APIShieldGetOperationsFilters + Filters *APIShieldGetOperationsFilters + // Pagination options to apply to the request. + Pagination *PaginationOptions +} + +// APIShieldGetOperationsFilters represents the filtering query parameters to set when retrieving operations +// +// See API documentation https://developers.cloudflare.com/api/operations/api-shield-endpoint-management-retrieve-information-about-all-operations-on-a-zone +type APIShieldGetOperationsFilters struct { + // Host filters results to only include the specified hosts. + Hosts []string + // Host filters results to only include the specified methods. + Methods []string + // Endpoint filter results to only include endpoints containing this pattern. + Endpoint string +} + +// APIShieldGetOperationResponse represents the response from the api_gateway/operations/{id} endpoint. +type APIShieldGetOperationResponse struct { + Result APIShieldOperation `json:"result"` + Response +} + +// APIShieldGetOperationsResponse represents the response from the api_gateway/operations endpoint. +type APIShieldGetOperationsResponse struct { + Result []APIShieldOperation `json:"result"` + ResultInfo `json:"result_info"` + Response +} + +// APIShieldDeleteOperationResponse represents the response from the api_gateway/operations/{id} endpoint (DELETE). +type APIShieldDeleteOperationResponse struct { + Result interface{} `json:"result"` + Response +} + +// GetAPIShieldOperation returns information about an operation +// +// API documentation https://developers.cloudflare.com/api/operations/api-shield-endpoint-management-retrieve-information-about-an-operation +func (api *API) GetAPIShieldOperation(ctx context.Context, rc *ResourceContainer, operationID string, params *APIShieldGetOperationParams) (*APIShieldOperation, error) { + uri := fmt.Sprintf("/zones/%s/api_gateway/operations/%s", rc.Identifier, operationID) + + if params != nil { + uri = strings.Join([]string{uri, params.Encode()}, "?") + } + + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return nil, err + } + + var asResponse APIShieldGetOperationResponse + err = json.Unmarshal(res, &asResponse) + if err != nil { + return nil, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return &asResponse.Result, nil +} + +// GetAPIShieldOperations retrieve information about all operations on a zone +// +// API documentation https://developers.cloudflare.com/api/operations/api-shield-endpoint-management-retrieve-information-about-all-operations-on-a-zone +func (api *API) GetAPIShieldOperations(ctx context.Context, rc *ResourceContainer, params *APIShieldGetOperationsParams) ([]APIShieldOperation, ResultInfo, error) { + uri := fmt.Sprintf("/zones/%s/api_gateway/operations", rc.Identifier) + if params != nil { + uri = strings.Join([]string{uri, params.Encode()}, "?") + } + + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return nil, ResultInfo{}, err + } + + var asResponse APIShieldGetOperationsResponse + err = json.Unmarshal(res, &asResponse) + if err != nil { + return nil, ResultInfo{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return asResponse.Result, asResponse.ResultInfo, nil +} + +// PostAPIShieldOperations add one or more operations to a zone. +// +// API documentation https://developers.cloudflare.com/api/operations/api-shield-endpoint-management-add-operations-to-a-zone +func (api *API) PostAPIShieldOperations(ctx context.Context, rc *ResourceContainer, operations []APIShieldCreateOperation) ([]APIShieldOperation, error) { + uri := fmt.Sprintf("/zones/%s/api_gateway/operations", rc.Identifier) + + res, err := api.makeRequestContext(ctx, http.MethodPost, uri, operations) + if err != nil { + return nil, err + } + + // Result should be all the operations added to the zone, similar to doing GetAPIShieldOperations + var asResponse APIShieldGetOperationsResponse + err = json.Unmarshal(res, &asResponse) + if err != nil { + return nil, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return asResponse.Result, nil +} + +// DeleteAPIShieldOperation deletes a single operation +// +// API documentation https://developers.cloudflare.com/api/operations/api-shield-endpoint-management-delete-an-operation +func (api *API) DeleteAPIShieldOperation(ctx context.Context, rc *ResourceContainer, operationID string) error { + uri := fmt.Sprintf("/zones/%s/api_gateway/operations/%s", rc.Identifier, operationID) + + res, err := api.makeRequestContext(ctx, http.MethodDelete, uri, nil) + if err != nil { + return err + } + + var asResponse APIShieldDeleteOperationResponse + err = json.Unmarshal(res, &asResponse) + if err != nil { + return fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return nil +} + +// Encode is a custom method for encoding APIShieldGetOperationParams into a usable HTTP +// query parameter string. +func (a APIShieldGetOperationParams) Encode() string { + v := url.Values{} + for _, f := range a.Features { + v.Add("feature", f) + } + + return v.Encode() +} + +// Encode is a custom method for encoding APIShieldGetOperationsParams into a usable HTTP +// query parameter string. +func (a APIShieldGetOperationsParams) Encode() string { + v := url.Values{} + for _, f := range a.Features { + v.Add("feature", f) + } + + if a.Direction != "" { + v.Set("direction", a.Direction) + } + + if a.OrderBy != "" { + v.Set("order", a.OrderBy) + } + + if a.Pagination != nil { + v.Set("page", strconv.Itoa(a.Pagination.Page)) + v.Set("per_page", strconv.Itoa(a.Pagination.PerPage)) + } + + if a.Filters != nil { + if a.Filters.Endpoint != "" { + v.Set("endpoint", a.Filters.Endpoint) + } + + for _, h := range a.Filters.Hosts { + v.Add("host", h) + } + + for _, m := range a.Filters.Methods { + v.Add("method", m) + } + } + + return v.Encode() +} diff --git a/api_shield_operations_test.go b/api_shield_operations_test.go new file mode 100644 index 00000000000..9e8a40ab03c --- /dev/null +++ b/api_shield_operations_test.go @@ -0,0 +1,480 @@ +package cloudflare + +import ( + "context" + "fmt" + "io" + "net/http" + "net/url" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const testAPIShieldOperationId = "9def2cb0-3ed0-4737-92ca-f09efa4718fd" + +func TestGetAPIShieldOperation(t *testing.T) { + setup() + t.Cleanup(teardown) + + endpoint := fmt.Sprintf("/zones/%s/api_gateway/operations/%s", testZoneID, testAPIShieldOperationId) + response := `{ + "success" : true, + "errors": [], + "messages": [], + "result": { + "operation_id": "9def2cb0-3ed0-4737-92ca-f09efa4718fd", + "method": "POST", + "host": "api.cloudflare.com", + "endpoint": "/client/v4/zones", + "last_updated": "2023-03-02T15:46:06.000000Z" + } + }` + + handler := func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method, "Expected method 'GET', got %s", r.Method) + require.Empty(t, r.URL.Query()) + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, response) + } + + mux.HandleFunc(endpoint, handler) + + actual, err := client.GetAPIShieldOperation( + context.Background(), + ZoneIdentifier(testZoneID), + testAPIShieldOperationId, + nil, + ) + + expected := &APIShieldOperation{ + APIShieldCreateOperation: APIShieldCreateOperation{ + Method: "POST", + Host: "api.cloudflare.com", + Endpoint: "/client/v4/zones", + }, + ID: testAPIShieldOperationId, + LastUpdated: time.Date(2023, time.March, 2, 15, 46, 6, 0, time.UTC), + Features: nil, + } + + if assert.NoError(t, err) { + assert.Equal(t, expected, actual) + } +} + +func TestGetAPIShieldOperationWithOptions(t *testing.T) { + endpoint := fmt.Sprintf("/zones/%s/api_gateway/operations/%s", testZoneID, testAPIShieldOperationId) + response := `{ + "success" : true, + "errors": [], + "messages": [], + "result": { + "operation_id": "9def2cb0-3ed0-4737-92ca-f09efa4718fd", + "method": "POST", + "host": "api.cloudflare.com", + "endpoint": "/client/v4/zones", + "last_updated": "2023-03-02T15:46:06.000000Z", + "features":{ + "thresholds":{}, + "parameter_schemas":{} + } + } + }` + + tests := []struct { + name string + getOptions *APIShieldGetOperationParams + expectedParams url.Values + }{ + { + name: "one feature", + getOptions: &APIShieldGetOperationParams{ + Features: []string{"thresholds"}, + }, + expectedParams: url.Values{ + "feature": []string{"thresholds"}, + }, + }, + { + name: "more than one feature", + getOptions: &APIShieldGetOperationParams{ + Features: []string{"thresholds", "parameter_schemas"}, + }, + expectedParams: url.Values{ + "feature": []string{"thresholds", "parameter_schemas"}, + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + setup() + t.Cleanup(teardown) + handler := func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method, "Expected method 'GET', got %s", r.Method) + require.Equal(t, test.expectedParams, r.URL.Query()) + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, response) + } + + mux.HandleFunc(endpoint, handler) + + actual, err := client.GetAPIShieldOperation( + context.Background(), + ZoneIdentifier(testZoneID), + testAPIShieldOperationId, + test.getOptions, + ) + + expected := &APIShieldOperation{ + APIShieldCreateOperation: APIShieldCreateOperation{ + Method: "POST", + Host: "api.cloudflare.com", + Endpoint: "/client/v4/zones", + }, + ID: "9def2cb0-3ed0-4737-92ca-f09efa4718fd", + LastUpdated: time.Date(2023, time.March, 2, 15, 46, 6, 0, time.UTC), + Features: map[string]any{ + "thresholds": map[string]any{}, + "parameter_schemas": map[string]any{}, + }, + } + + if assert.NoError(t, err) { + assert.Equal(t, expected, actual) + } + }) + } +} + +func TestGetAPIShieldOperations(t *testing.T) { + endpoint := fmt.Sprintf("/zones/%s/api_gateway/operations", testZoneID) + response := `{ + "success" : true, + "errors": [], + "messages": [], + "result": [ + { + "operation_id": "9def2cb0-3ed0-4737-92ca-f09efa4718fd", + "method": "POST", + "host": "api.cloudflare.com", + "endpoint": "/client/v4/zones", + "last_updated": "2023-03-02T15:46:06.000000Z" + } + + ], + "result_info": { + "page": 3, + "per_page": 20, + "count": 1, + "total_count": 2000 + } + }` + + setup() + t.Cleanup(teardown) + handler := func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method, "Expected method 'GET', got %s", r.Method) + require.Empty(t, r.URL.Query()) + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, response) + } + + mux.HandleFunc(endpoint, handler) + + actual, actualResultInfo, err := client.GetAPIShieldOperations( + context.Background(), + ZoneIdentifier(testZoneID), + nil, + ) + + expectedOps := []APIShieldOperation{ + { + APIShieldCreateOperation: APIShieldCreateOperation{ + Method: "POST", + Host: "api.cloudflare.com", + Endpoint: "/client/v4/zones", + }, + ID: "9def2cb0-3ed0-4737-92ca-f09efa4718fd", + LastUpdated: time.Date(2023, time.March, 2, 15, 46, 6, 0, time.UTC), + Features: nil, + }, + } + + expectedResultInfo := ResultInfo{ + Page: 3, + PerPage: 20, + Count: 1, + Total: 2000, + } + + if assert.NoError(t, err) { + assert.Equal(t, expectedOps, actual) + assert.Equal(t, expectedResultInfo, actualResultInfo) + } +} + +func TestGetAPIShieldOperationsWithOptions(t *testing.T) { + endpoint := fmt.Sprintf("/zones/%s/api_gateway/operations", testZoneID) + response := `{ + "success" : true, + "errors": [], + "messages": [], + "result": [ + { + "operation_id": "9def2cb0-3ed0-4737-92ca-f09efa4718fd", + "method": "POST", + "host": "api.cloudflare.com", + "endpoint": "/client/v4/zones", + "last_updated": "2023-03-02T15:46:06.000000Z", + "features": { + "thresholds": {} + } + } + ], + "result_info": { + "page": 3, + "per_page": 20, + "count": 1, + "total_count": 2000 + } + }` + + tests := []struct { + name string + params *APIShieldGetOperationsParams + expectedParams url.Values + }{ + { + name: "all params", + params: &APIShieldGetOperationsParams{ + Features: []string{"thresholds", "parameter_schemas"}, + Direction: "desc", + OrderBy: "host", + Filters: &APIShieldGetOperationsFilters{ + Hosts: []string{"api.cloudflare.com", "developers.cloudflare.com"}, + Methods: []string{"GET", "PUT"}, + Endpoint: "/client", + }, + Pagination: &PaginationOptions{ + Page: 1, + PerPage: 25, + }, + }, + expectedParams: url.Values{ + "feature": []string{"thresholds", "parameter_schemas"}, + "direction": []string{"desc"}, + "order": []string{"host"}, + "host": []string{"api.cloudflare.com", "developers.cloudflare.com"}, + "method": []string{"GET", "PUT"}, + "endpoint": []string{"/client"}, + "page": []string{"1"}, + "per_page": []string{"25"}, + }, + }, + { + name: "features only", + params: &APIShieldGetOperationsParams{ + Features: []string{"thresholds", "parameter_schemas"}, + }, + expectedParams: url.Values{ + "feature": []string{"thresholds", "parameter_schemas"}, + }, + }, + { + name: "direction only", + params: &APIShieldGetOperationsParams{ + Direction: "desc", + }, + expectedParams: url.Values{ + "direction": []string{"desc"}, + }, + }, + { + name: "order only", + params: &APIShieldGetOperationsParams{ + OrderBy: "host", + }, + expectedParams: url.Values{ + "order": []string{"host"}, + }, + }, + { + name: "hosts only", + params: &APIShieldGetOperationsParams{ + Filters: &APIShieldGetOperationsFilters{ + Hosts: []string{"api.cloudflare.com", "developers.cloudflare.com"}, + }, + }, + expectedParams: url.Values{ + "host": []string{"api.cloudflare.com", "developers.cloudflare.com"}, + }, + }, + { + name: "methods only", + params: &APIShieldGetOperationsParams{ + Filters: &APIShieldGetOperationsFilters{ + Methods: []string{"GET", "PUT"}, + }, + }, + expectedParams: url.Values{ + "method": []string{"GET", "PUT"}, + }, + }, + { + name: "endpoint only", + params: &APIShieldGetOperationsParams{ + Filters: &APIShieldGetOperationsFilters{ + Endpoint: "/client", + }, + }, + expectedParams: url.Values{ + "endpoint": []string{"/client"}, + }, + }, + { + name: "pagination only", + params: &APIShieldGetOperationsParams{ + Pagination: &PaginationOptions{ + Page: 1, + PerPage: 25, + }, + }, + expectedParams: url.Values{ + "page": []string{"1"}, + "per_page": []string{"25"}, + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + setup() + t.Cleanup(teardown) + handler := func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method, "Expected method 'GET', got %s", r.Method) + require.Equal(t, test.expectedParams, r.URL.Query()) + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, response) + } + + mux.HandleFunc(endpoint, handler) + + actual, _, err := client.GetAPIShieldOperations( + context.Background(), + ZoneIdentifier(testZoneID), + test.params, + ) + + expected := []APIShieldOperation{ + { + APIShieldCreateOperation: APIShieldCreateOperation{ + Method: "POST", + Host: "api.cloudflare.com", + Endpoint: "/client/v4/zones", + }, + ID: "9def2cb0-3ed0-4737-92ca-f09efa4718fd", + LastUpdated: time.Date(2023, time.March, 2, 15, 46, 6, 0, time.UTC), + Features: map[string]any{ + "thresholds": map[string]any{}, + }, + }, + } + + if assert.NoError(t, err) { + assert.Equal(t, expected, actual) + } + }) + } +} + +func TestPostAPIShieldOperations(t *testing.T) { + setup() + t.Cleanup(teardown) + + endpoint := fmt.Sprintf("/zones/%s/api_gateway/operations", testZoneID) + response := `{ + "success" : true, + "errors": [], + "messages": [], + "result": [ + { + "operation_id": "9def2cb0-3ed0-4737-92ca-f09efa4718fd", + "method": "POST", + "host": "api.cloudflare.com", + "endpoint": "/client/v4/zones", + "last_updated": "2023-03-02T15:46:06.000000Z" + } + ] + }` + + handler := func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + + body, err := io.ReadAll(r.Body) + require.NoError(t, err) + require.Equal(t, []byte(`[{"method":"POST","host":"api.cloudflare.com","endpoint":"/client/v4/zones"}]`), body) + + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, response) + } + + mux.HandleFunc(endpoint, handler) + + actual, err := client.PostAPIShieldOperations( + context.Background(), + ZoneIdentifier(testZoneID), + []APIShieldCreateOperation{ + { + Method: "POST", + Host: "api.cloudflare.com", + Endpoint: "/client/v4/zones", + }, + }, + ) + + expected := []APIShieldOperation{ + { + APIShieldCreateOperation: APIShieldCreateOperation{ + Method: "POST", + Host: "api.cloudflare.com", + Endpoint: "/client/v4/zones", + }, + ID: "9def2cb0-3ed0-4737-92ca-f09efa4718fd", + LastUpdated: time.Date(2023, time.March, 2, 15, 46, 6, 0, time.UTC), + Features: nil, + }, + } + + if assert.NoError(t, err) { + assert.Equal(t, expected, actual) + } +} + +func TestDeleteAPIShieldOperation(t *testing.T) { + setup() + t.Cleanup(teardown) + + endpoint := fmt.Sprintf("/zones/%s/api_gateway/operations/%s", testZoneID, testAPIShieldOperationId) + response := `{"result":{},"success":true,"errors":[],"messages":[]}` + + handler := func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodDelete, r.Method, "Expected method 'DELETE', got %s", r.Method) + require.Empty(t, r.URL.Query()) + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, response) + } + + mux.HandleFunc(endpoint, handler) + + err := client.DeleteAPIShieldOperation( + context.Background(), + ZoneIdentifier(testZoneID), + testAPIShieldOperationId, + ) + + assert.NoError(t, err) +} From 2c4380c0cb9ca9ec185937eafaae318b542cfe3d Mon Sep 17 00:00:00 2001 From: Daniel Harper <529730+djhworld@users.noreply.github.com> Date: Thu, 14 Sep 2023 12:21:43 +0100 Subject: [PATCH 2/3] Update Get/Post/Delete operations in API Shield Endpoint Management to follow library conventions - Using `github.com/goccy/go-json` over `encoding/json` - Renamed `*GetOperations` to `*ListOperations` - Changed methods to accept 3 parameters only, and used method specific `*Params` structs - Renamed `*Param` structs to be paired with the methods they are associated with (e.g. `GetAPIShieldOperationParam`) - Use `buildURI` method + added struct tags so query parameters are encoded using go-queryparams (removed custom `Encode` methods) --- api_shield_operations.go | 152 +++++++++++++--------------------- api_shield_operations_test.go | 92 ++++++++++---------- 2 files changed, 107 insertions(+), 137 deletions(-) diff --git a/api_shield_operations.go b/api_shield_operations.go index 86e9ee2c370..f4b0cd5bd21 100644 --- a/api_shield_operations.go +++ b/api_shield_operations.go @@ -2,66 +2,82 @@ package cloudflare import ( "context" - "encoding/json" "fmt" "net/http" - "net/url" - "strconv" - "strings" "time" -) -// APIShieldCreateOperation should be used when creating an operation in API Shield Endpoint Management. -type APIShieldCreateOperation struct { - Method string `json:"method"` - Host string `json:"host"` - Endpoint string `json:"endpoint"` -} + "github.com/goccy/go-json" +) // APIShieldOperation represents an operation stored in API Shield Endpoint Management. type APIShieldOperation struct { - APIShieldCreateOperation + APIShieldBasicOperation ID string `json:"operation_id"` LastUpdated time.Time `json:"last_updated"` Features map[string]any `json:"features,omitempty"` } -// APIShieldGetOperationParams represents the query parameters to set when retrieving an operation. +// GetAPIShieldOperationParams represents the parameters to pass when retrieving an operation. // // See API documentation https://developers.cloudflare.com/api/operations/api-shield-endpoint-management-retrieve-information-about-an-operation -type APIShieldGetOperationParams struct { +type GetAPIShieldOperationParams struct { + // The Operation ID to retrieve + OperationID string `url:"-"` // Features represents a set of features to return in `features` object when // performing making read requests against an Operation or listing operations. - Features []string + Features []string `url:"feature,omitempty"` +} + +// CreateAPIShieldOperationsParams represents the parameters to pass when adding one or more operations. +// +// See API documentation https://developers.cloudflare.com/api/operations/api-shield-endpoint-management-add-operations-to-a-zone +type CreateAPIShieldOperationsParams struct { + // Operations are a slice of operations to be created in API Shield Endpoint Management + Operations []APIShieldBasicOperation `url:"-"` +} + +// APIShieldBasicOperation should be used when creating an operation in API Shield Endpoint Management. +type APIShieldBasicOperation struct { + Method string `json:"method"` + Host string `json:"host"` + Endpoint string `json:"endpoint"` +} + +// DeleteAPIShieldOperationParams represents the parameters to pass to delete an operation. +// +// See API documentation https://developers.cloudflare.com/api/operations/api-shield-endpoint-management-delete-an-operation +type DeleteAPIShieldOperationParams struct { + // OperationID is the operation to be deleted + OperationID string `url:"-"` } -// APIShieldGetOperationsParams represents the query parameters to set when retrieving operations +// ListAPIShieldOperationsParams represents the parameters to pass when retrieving operations // // See API documentation https://developers.cloudflare.com/api/operations/api-shield-endpoint-management-retrieve-information-about-all-operations-on-a-zone -type APIShieldGetOperationsParams struct { +type ListAPIShieldOperationsParams struct { // Features represents a set of features to return in `features` object when // performing making read requests against an Operation or listing operations. - Features []string + Features []string `url:"feature,omitempty"` // Direction to order results. - Direction string + Direction string `url:"direction,omitempty"` // OrderBy when requesting a feature, the feature keys are available for ordering as well, e.g., thresholds.suggested_threshold. - OrderBy string + OrderBy string `url:"order,omitempty"` // Filters to only return operations that match filtering criteria, see APIShieldGetOperationsFilters - Filters *APIShieldGetOperationsFilters + APIShieldListOperationsFilters // Pagination options to apply to the request. - Pagination *PaginationOptions + PaginationOptions } -// APIShieldGetOperationsFilters represents the filtering query parameters to set when retrieving operations +// APIShieldListOperationsFilters represents the filtering query parameters to set when retrieving operations // // See API documentation https://developers.cloudflare.com/api/operations/api-shield-endpoint-management-retrieve-information-about-all-operations-on-a-zone -type APIShieldGetOperationsFilters struct { - // Host filters results to only include the specified hosts. - Hosts []string - // Host filters results to only include the specified methods. - Methods []string +type APIShieldListOperationsFilters struct { + // Hosts filters results to only include the specified hosts. + Hosts []string `url:"host,omitempty"` + // Methods filters results to only include the specified methods. + Methods []string `url:"method,omitempty"` // Endpoint filter results to only include endpoints containing this pattern. - Endpoint string + Endpoint string `url:"endpoint,omitempty"` } // APIShieldGetOperationResponse represents the response from the api_gateway/operations/{id} endpoint. @@ -86,12 +102,10 @@ type APIShieldDeleteOperationResponse struct { // GetAPIShieldOperation returns information about an operation // // API documentation https://developers.cloudflare.com/api/operations/api-shield-endpoint-management-retrieve-information-about-an-operation -func (api *API) GetAPIShieldOperation(ctx context.Context, rc *ResourceContainer, operationID string, params *APIShieldGetOperationParams) (*APIShieldOperation, error) { - uri := fmt.Sprintf("/zones/%s/api_gateway/operations/%s", rc.Identifier, operationID) +func (api *API) GetAPIShieldOperation(ctx context.Context, rc *ResourceContainer, params GetAPIShieldOperationParams) (*APIShieldOperation, error) { + path := fmt.Sprintf("/zones/%s/api_gateway/operations/%s", rc.Identifier, params.OperationID) - if params != nil { - uri = strings.Join([]string{uri, params.Encode()}, "?") - } + uri := buildURI(path, params) res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) if err != nil { @@ -107,14 +121,13 @@ func (api *API) GetAPIShieldOperation(ctx context.Context, rc *ResourceContainer return &asResponse.Result, nil } -// GetAPIShieldOperations retrieve information about all operations on a zone +// ListAPIShieldOperations retrieve information about all operations on a zone // // API documentation https://developers.cloudflare.com/api/operations/api-shield-endpoint-management-retrieve-information-about-all-operations-on-a-zone -func (api *API) GetAPIShieldOperations(ctx context.Context, rc *ResourceContainer, params *APIShieldGetOperationsParams) ([]APIShieldOperation, ResultInfo, error) { - uri := fmt.Sprintf("/zones/%s/api_gateway/operations", rc.Identifier) - if params != nil { - uri = strings.Join([]string{uri, params.Encode()}, "?") - } +func (api *API) ListAPIShieldOperations(ctx context.Context, rc *ResourceContainer, params ListAPIShieldOperationsParams) ([]APIShieldOperation, ResultInfo, error) { + path := fmt.Sprintf("/zones/%s/api_gateway/operations", rc.Identifier) + + uri := buildURI(path, params) res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) if err != nil { @@ -130,13 +143,13 @@ func (api *API) GetAPIShieldOperations(ctx context.Context, rc *ResourceContaine return asResponse.Result, asResponse.ResultInfo, nil } -// PostAPIShieldOperations add one or more operations to a zone. +// CreateAPIShieldOperations add one or more operations to a zone. // // API documentation https://developers.cloudflare.com/api/operations/api-shield-endpoint-management-add-operations-to-a-zone -func (api *API) PostAPIShieldOperations(ctx context.Context, rc *ResourceContainer, operations []APIShieldCreateOperation) ([]APIShieldOperation, error) { +func (api *API) CreateAPIShieldOperations(ctx context.Context, rc *ResourceContainer, params CreateAPIShieldOperationsParams) ([]APIShieldOperation, error) { uri := fmt.Sprintf("/zones/%s/api_gateway/operations", rc.Identifier) - res, err := api.makeRequestContext(ctx, http.MethodPost, uri, operations) + res, err := api.makeRequestContext(ctx, http.MethodPost, uri, params.Operations) if err != nil { return nil, err } @@ -154,8 +167,8 @@ func (api *API) PostAPIShieldOperations(ctx context.Context, rc *ResourceContain // DeleteAPIShieldOperation deletes a single operation // // API documentation https://developers.cloudflare.com/api/operations/api-shield-endpoint-management-delete-an-operation -func (api *API) DeleteAPIShieldOperation(ctx context.Context, rc *ResourceContainer, operationID string) error { - uri := fmt.Sprintf("/zones/%s/api_gateway/operations/%s", rc.Identifier, operationID) +func (api *API) DeleteAPIShieldOperation(ctx context.Context, rc *ResourceContainer, params DeleteAPIShieldOperationParams) error { + uri := fmt.Sprintf("/zones/%s/api_gateway/operations/%s", rc.Identifier, params.OperationID) res, err := api.makeRequestContext(ctx, http.MethodDelete, uri, nil) if err != nil { @@ -170,52 +183,3 @@ func (api *API) DeleteAPIShieldOperation(ctx context.Context, rc *ResourceContai return nil } - -// Encode is a custom method for encoding APIShieldGetOperationParams into a usable HTTP -// query parameter string. -func (a APIShieldGetOperationParams) Encode() string { - v := url.Values{} - for _, f := range a.Features { - v.Add("feature", f) - } - - return v.Encode() -} - -// Encode is a custom method for encoding APIShieldGetOperationsParams into a usable HTTP -// query parameter string. -func (a APIShieldGetOperationsParams) Encode() string { - v := url.Values{} - for _, f := range a.Features { - v.Add("feature", f) - } - - if a.Direction != "" { - v.Set("direction", a.Direction) - } - - if a.OrderBy != "" { - v.Set("order", a.OrderBy) - } - - if a.Pagination != nil { - v.Set("page", strconv.Itoa(a.Pagination.Page)) - v.Set("per_page", strconv.Itoa(a.Pagination.PerPage)) - } - - if a.Filters != nil { - if a.Filters.Endpoint != "" { - v.Set("endpoint", a.Filters.Endpoint) - } - - for _, h := range a.Filters.Hosts { - v.Add("host", h) - } - - for _, m := range a.Filters.Methods { - v.Add("method", m) - } - } - - return v.Encode() -} diff --git a/api_shield_operations_test.go b/api_shield_operations_test.go index 9e8a40ab03c..44f994ef401 100644 --- a/api_shield_operations_test.go +++ b/api_shield_operations_test.go @@ -45,12 +45,13 @@ func TestGetAPIShieldOperation(t *testing.T) { actual, err := client.GetAPIShieldOperation( context.Background(), ZoneIdentifier(testZoneID), - testAPIShieldOperationId, - nil, + GetAPIShieldOperationParams{ + OperationID: testAPIShieldOperationId, + }, ) expected := &APIShieldOperation{ - APIShieldCreateOperation: APIShieldCreateOperation{ + APIShieldBasicOperation: APIShieldBasicOperation{ Method: "POST", Host: "api.cloudflare.com", Endpoint: "/client/v4/zones", @@ -65,7 +66,7 @@ func TestGetAPIShieldOperation(t *testing.T) { } } -func TestGetAPIShieldOperationWithOptions(t *testing.T) { +func TestGetAPIShieldOperationWithParams(t *testing.T) { endpoint := fmt.Sprintf("/zones/%s/api_gateway/operations/%s", testZoneID, testAPIShieldOperationId) response := `{ "success" : true, @@ -86,13 +87,14 @@ func TestGetAPIShieldOperationWithOptions(t *testing.T) { tests := []struct { name string - getOptions *APIShieldGetOperationParams + getParams GetAPIShieldOperationParams expectedParams url.Values }{ { name: "one feature", - getOptions: &APIShieldGetOperationParams{ - Features: []string{"thresholds"}, + getParams: GetAPIShieldOperationParams{ + OperationID: testAPIShieldOperationId, + Features: []string{"thresholds"}, }, expectedParams: url.Values{ "feature": []string{"thresholds"}, @@ -100,8 +102,9 @@ func TestGetAPIShieldOperationWithOptions(t *testing.T) { }, { name: "more than one feature", - getOptions: &APIShieldGetOperationParams{ - Features: []string{"thresholds", "parameter_schemas"}, + getParams: GetAPIShieldOperationParams{ + OperationID: testAPIShieldOperationId, + Features: []string{"thresholds", "parameter_schemas"}, }, expectedParams: url.Values{ "feature": []string{"thresholds", "parameter_schemas"}, @@ -125,12 +128,11 @@ func TestGetAPIShieldOperationWithOptions(t *testing.T) { actual, err := client.GetAPIShieldOperation( context.Background(), ZoneIdentifier(testZoneID), - testAPIShieldOperationId, - test.getOptions, + test.getParams, ) expected := &APIShieldOperation{ - APIShieldCreateOperation: APIShieldCreateOperation{ + APIShieldBasicOperation: APIShieldBasicOperation{ Method: "POST", Host: "api.cloudflare.com", Endpoint: "/client/v4/zones", @@ -150,7 +152,7 @@ func TestGetAPIShieldOperationWithOptions(t *testing.T) { } } -func TestGetAPIShieldOperations(t *testing.T) { +func TestListAPIShieldOperations(t *testing.T) { endpoint := fmt.Sprintf("/zones/%s/api_gateway/operations", testZoneID) response := `{ "success" : true, @@ -185,15 +187,15 @@ func TestGetAPIShieldOperations(t *testing.T) { mux.HandleFunc(endpoint, handler) - actual, actualResultInfo, err := client.GetAPIShieldOperations( + actual, actualResultInfo, err := client.ListAPIShieldOperations( context.Background(), ZoneIdentifier(testZoneID), - nil, + ListAPIShieldOperationsParams{}, ) expectedOps := []APIShieldOperation{ { - APIShieldCreateOperation: APIShieldCreateOperation{ + APIShieldBasicOperation: APIShieldBasicOperation{ Method: "POST", Host: "api.cloudflare.com", Endpoint: "/client/v4/zones", @@ -217,7 +219,7 @@ func TestGetAPIShieldOperations(t *testing.T) { } } -func TestGetAPIShieldOperationsWithOptions(t *testing.T) { +func TestListAPIShieldOperationsWithParams(t *testing.T) { endpoint := fmt.Sprintf("/zones/%s/api_gateway/operations", testZoneID) response := `{ "success" : true, @@ -245,21 +247,21 @@ func TestGetAPIShieldOperationsWithOptions(t *testing.T) { tests := []struct { name string - params *APIShieldGetOperationsParams + params ListAPIShieldOperationsParams expectedParams url.Values }{ { name: "all params", - params: &APIShieldGetOperationsParams{ + params: ListAPIShieldOperationsParams{ Features: []string{"thresholds", "parameter_schemas"}, Direction: "desc", OrderBy: "host", - Filters: &APIShieldGetOperationsFilters{ + APIShieldListOperationsFilters: APIShieldListOperationsFilters{ Hosts: []string{"api.cloudflare.com", "developers.cloudflare.com"}, Methods: []string{"GET", "PUT"}, Endpoint: "/client", }, - Pagination: &PaginationOptions{ + PaginationOptions: PaginationOptions{ Page: 1, PerPage: 25, }, @@ -277,7 +279,7 @@ func TestGetAPIShieldOperationsWithOptions(t *testing.T) { }, { name: "features only", - params: &APIShieldGetOperationsParams{ + params: ListAPIShieldOperationsParams{ Features: []string{"thresholds", "parameter_schemas"}, }, expectedParams: url.Values{ @@ -286,7 +288,7 @@ func TestGetAPIShieldOperationsWithOptions(t *testing.T) { }, { name: "direction only", - params: &APIShieldGetOperationsParams{ + params: ListAPIShieldOperationsParams{ Direction: "desc", }, expectedParams: url.Values{ @@ -295,7 +297,7 @@ func TestGetAPIShieldOperationsWithOptions(t *testing.T) { }, { name: "order only", - params: &APIShieldGetOperationsParams{ + params: ListAPIShieldOperationsParams{ OrderBy: "host", }, expectedParams: url.Values{ @@ -304,8 +306,8 @@ func TestGetAPIShieldOperationsWithOptions(t *testing.T) { }, { name: "hosts only", - params: &APIShieldGetOperationsParams{ - Filters: &APIShieldGetOperationsFilters{ + params: ListAPIShieldOperationsParams{ + APIShieldListOperationsFilters: APIShieldListOperationsFilters{ Hosts: []string{"api.cloudflare.com", "developers.cloudflare.com"}, }, }, @@ -315,8 +317,8 @@ func TestGetAPIShieldOperationsWithOptions(t *testing.T) { }, { name: "methods only", - params: &APIShieldGetOperationsParams{ - Filters: &APIShieldGetOperationsFilters{ + params: ListAPIShieldOperationsParams{ + APIShieldListOperationsFilters: APIShieldListOperationsFilters{ Methods: []string{"GET", "PUT"}, }, }, @@ -326,8 +328,8 @@ func TestGetAPIShieldOperationsWithOptions(t *testing.T) { }, { name: "endpoint only", - params: &APIShieldGetOperationsParams{ - Filters: &APIShieldGetOperationsFilters{ + params: ListAPIShieldOperationsParams{ + APIShieldListOperationsFilters: APIShieldListOperationsFilters{ Endpoint: "/client", }, }, @@ -337,8 +339,8 @@ func TestGetAPIShieldOperationsWithOptions(t *testing.T) { }, { name: "pagination only", - params: &APIShieldGetOperationsParams{ - Pagination: &PaginationOptions{ + params: ListAPIShieldOperationsParams{ + PaginationOptions: PaginationOptions{ Page: 1, PerPage: 25, }, @@ -363,7 +365,7 @@ func TestGetAPIShieldOperationsWithOptions(t *testing.T) { mux.HandleFunc(endpoint, handler) - actual, _, err := client.GetAPIShieldOperations( + actual, _, err := client.ListAPIShieldOperations( context.Background(), ZoneIdentifier(testZoneID), test.params, @@ -371,7 +373,7 @@ func TestGetAPIShieldOperationsWithOptions(t *testing.T) { expected := []APIShieldOperation{ { - APIShieldCreateOperation: APIShieldCreateOperation{ + APIShieldBasicOperation: APIShieldBasicOperation{ Method: "POST", Host: "api.cloudflare.com", Endpoint: "/client/v4/zones", @@ -391,7 +393,7 @@ func TestGetAPIShieldOperationsWithOptions(t *testing.T) { } } -func TestPostAPIShieldOperations(t *testing.T) { +func TestCreateAPIShieldOperations(t *testing.T) { setup() t.Cleanup(teardown) @@ -424,21 +426,23 @@ func TestPostAPIShieldOperations(t *testing.T) { mux.HandleFunc(endpoint, handler) - actual, err := client.PostAPIShieldOperations( + actual, err := client.CreateAPIShieldOperations( context.Background(), ZoneIdentifier(testZoneID), - []APIShieldCreateOperation{ - { - Method: "POST", - Host: "api.cloudflare.com", - Endpoint: "/client/v4/zones", + CreateAPIShieldOperationsParams{ + Operations: []APIShieldBasicOperation{ + { + Method: "POST", + Host: "api.cloudflare.com", + Endpoint: "/client/v4/zones", + }, }, }, ) expected := []APIShieldOperation{ { - APIShieldCreateOperation: APIShieldCreateOperation{ + APIShieldBasicOperation: APIShieldBasicOperation{ Method: "POST", Host: "api.cloudflare.com", Endpoint: "/client/v4/zones", @@ -473,7 +477,9 @@ func TestDeleteAPIShieldOperation(t *testing.T) { err := client.DeleteAPIShieldOperation( context.Background(), ZoneIdentifier(testZoneID), - testAPIShieldOperationId, + DeleteAPIShieldOperationParams{ + OperationID: testAPIShieldOperationId, + }, ) assert.NoError(t, err) From ebcf11f044e69b1c64d42f0f27a90e828a486572 Mon Sep 17 00:00:00 2001 From: Jacob Bednarz Date: Fri, 15 Sep 2023 12:04:24 +1000 Subject: [PATCH 3/3] swap LastUpdated to *time.Time --- api_shield_operations.go | 12 ++++++------ api_shield_operations_test.go | 15 ++++++++++----- 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/api_shield_operations.go b/api_shield_operations.go index f4b0cd5bd21..1d89a1754b2 100644 --- a/api_shield_operations.go +++ b/api_shield_operations.go @@ -13,13 +13,13 @@ import ( type APIShieldOperation struct { APIShieldBasicOperation ID string `json:"operation_id"` - LastUpdated time.Time `json:"last_updated"` + LastUpdated *time.Time `json:"last_updated"` Features map[string]any `json:"features,omitempty"` } // GetAPIShieldOperationParams represents the parameters to pass when retrieving an operation. // -// See API documentation https://developers.cloudflare.com/api/operations/api-shield-endpoint-management-retrieve-information-about-an-operation +// API documentation: https://developers.cloudflare.com/api/operations/api-shield-endpoint-management-retrieve-information-about-an-operation type GetAPIShieldOperationParams struct { // The Operation ID to retrieve OperationID string `url:"-"` @@ -30,7 +30,7 @@ type GetAPIShieldOperationParams struct { // CreateAPIShieldOperationsParams represents the parameters to pass when adding one or more operations. // -// See API documentation https://developers.cloudflare.com/api/operations/api-shield-endpoint-management-add-operations-to-a-zone +// API documentation: https://developers.cloudflare.com/api/operations/api-shield-endpoint-management-add-operations-to-a-zone type CreateAPIShieldOperationsParams struct { // Operations are a slice of operations to be created in API Shield Endpoint Management Operations []APIShieldBasicOperation `url:"-"` @@ -45,7 +45,7 @@ type APIShieldBasicOperation struct { // DeleteAPIShieldOperationParams represents the parameters to pass to delete an operation. // -// See API documentation https://developers.cloudflare.com/api/operations/api-shield-endpoint-management-delete-an-operation +// API documentation: https://developers.cloudflare.com/api/operations/api-shield-endpoint-management-delete-an-operation type DeleteAPIShieldOperationParams struct { // OperationID is the operation to be deleted OperationID string `url:"-"` @@ -53,7 +53,7 @@ type DeleteAPIShieldOperationParams struct { // ListAPIShieldOperationsParams represents the parameters to pass when retrieving operations // -// See API documentation https://developers.cloudflare.com/api/operations/api-shield-endpoint-management-retrieve-information-about-all-operations-on-a-zone +// API documentation: https://developers.cloudflare.com/api/operations/api-shield-endpoint-management-retrieve-information-about-all-operations-on-a-zone type ListAPIShieldOperationsParams struct { // Features represents a set of features to return in `features` object when // performing making read requests against an Operation or listing operations. @@ -70,7 +70,7 @@ type ListAPIShieldOperationsParams struct { // APIShieldListOperationsFilters represents the filtering query parameters to set when retrieving operations // -// See API documentation https://developers.cloudflare.com/api/operations/api-shield-endpoint-management-retrieve-information-about-all-operations-on-a-zone +// API documentation: https://developers.cloudflare.com/api/operations/api-shield-endpoint-management-retrieve-information-about-all-operations-on-a-zone type APIShieldListOperationsFilters struct { // Hosts filters results to only include the specified hosts. Hosts []string `url:"host,omitempty"` diff --git a/api_shield_operations_test.go b/api_shield_operations_test.go index 44f994ef401..fdcc25cc85b 100644 --- a/api_shield_operations_test.go +++ b/api_shield_operations_test.go @@ -50,6 +50,7 @@ func TestGetAPIShieldOperation(t *testing.T) { }, ) + time := time.Date(2023, time.March, 2, 15, 46, 6, 0, time.UTC) expected := &APIShieldOperation{ APIShieldBasicOperation: APIShieldBasicOperation{ Method: "POST", @@ -57,7 +58,7 @@ func TestGetAPIShieldOperation(t *testing.T) { Endpoint: "/client/v4/zones", }, ID: testAPIShieldOperationId, - LastUpdated: time.Date(2023, time.March, 2, 15, 46, 6, 0, time.UTC), + LastUpdated: &time, Features: nil, } @@ -131,6 +132,7 @@ func TestGetAPIShieldOperationWithParams(t *testing.T) { test.getParams, ) + time := time.Date(2023, time.March, 2, 15, 46, 6, 0, time.UTC) expected := &APIShieldOperation{ APIShieldBasicOperation: APIShieldBasicOperation{ Method: "POST", @@ -138,7 +140,7 @@ func TestGetAPIShieldOperationWithParams(t *testing.T) { Endpoint: "/client/v4/zones", }, ID: "9def2cb0-3ed0-4737-92ca-f09efa4718fd", - LastUpdated: time.Date(2023, time.March, 2, 15, 46, 6, 0, time.UTC), + LastUpdated: &time, Features: map[string]any{ "thresholds": map[string]any{}, "parameter_schemas": map[string]any{}, @@ -193,6 +195,7 @@ func TestListAPIShieldOperations(t *testing.T) { ListAPIShieldOperationsParams{}, ) + time := time.Date(2023, time.March, 2, 15, 46, 6, 0, time.UTC) expectedOps := []APIShieldOperation{ { APIShieldBasicOperation: APIShieldBasicOperation{ @@ -201,7 +204,7 @@ func TestListAPIShieldOperations(t *testing.T) { Endpoint: "/client/v4/zones", }, ID: "9def2cb0-3ed0-4737-92ca-f09efa4718fd", - LastUpdated: time.Date(2023, time.March, 2, 15, 46, 6, 0, time.UTC), + LastUpdated: &time, Features: nil, }, } @@ -371,6 +374,7 @@ func TestListAPIShieldOperationsWithParams(t *testing.T) { test.params, ) + time := time.Date(2023, time.March, 2, 15, 46, 6, 0, time.UTC) expected := []APIShieldOperation{ { APIShieldBasicOperation: APIShieldBasicOperation{ @@ -379,7 +383,7 @@ func TestListAPIShieldOperationsWithParams(t *testing.T) { Endpoint: "/client/v4/zones", }, ID: "9def2cb0-3ed0-4737-92ca-f09efa4718fd", - LastUpdated: time.Date(2023, time.March, 2, 15, 46, 6, 0, time.UTC), + LastUpdated: &time, Features: map[string]any{ "thresholds": map[string]any{}, }, @@ -440,6 +444,7 @@ func TestCreateAPIShieldOperations(t *testing.T) { }, ) + time := time.Date(2023, time.March, 2, 15, 46, 6, 0, time.UTC) expected := []APIShieldOperation{ { APIShieldBasicOperation: APIShieldBasicOperation{ @@ -448,7 +453,7 @@ func TestCreateAPIShieldOperations(t *testing.T) { Endpoint: "/client/v4/zones", }, ID: "9def2cb0-3ed0-4737-92ca-f09efa4718fd", - LastUpdated: time.Date(2023, time.March, 2, 15, 46, 6, 0, time.UTC), + LastUpdated: &time, Features: nil, }, }