Repository: cloudflare/cloudflare-go
Pull request #1406 (2023-09-19 14:44:14)

Add support for Get/Post/Delete/Patch schemas for API Shield Schema Validation 2.0
This change adds support for the following API Shield related endpoints related to Schema Validation 2.0:

- Retrieve information about all schemas on a zone (https://developers.cloudflare.com/api/operations/api-shield-schema-validation-retrieve-information-about-all-schemas)
- Retrieve information about a specific schema on a zone (https://developers.cloudflare.com/api/operations/api-shield-schema-validation-retrieve-information-about-specific-schema) 
- Upload a schema to a zone (https://developers.cloudflare.com/api/operations/api-shield-schema-validation-post-schema)
- Enable validation for a schema (https://developers.cloudflare.com/api/operations/api-shield-schema-validation-enable-validation-for-a-schema)
- Delete a schema (https://developers.cloudflare.com/api/operations/api-shield-schema-delete-a-schema)

## Description

This change adds support for API Shield related endpoints related to managing schemas for API Shield Schema Validation 2.0. This is so they can be used from the library and will enable this feature to be added to terraform in the future.

## Has your change been tested?

- Unit tests
- Local testing using API


## Types of changes

What sort of change does your code introduce/modify?

- [x] New feature (non-breaking change which adds functionality)

## Checklist:

- [x] My code follows the code style of this project.
- [x] My change requires a change to the documentation.
- [x] I have updated the documentation accordingly.
- [x] I have added tests to cover my changes.
- [x] All new and existing tests passed.
- [x] This change is using publicly documented in [cloudflare/api-schemas](https://github.com/cloudflare/api-schemas) 
      and relies on stable APIs. ([API Shield Schema Validation 2.0](https://developers.cloudflare.com/api/operations/api-shield-schema-validation-retrieve-information-about-all-schemas))

[1]: https://help.github.com/articles/closing-issues-using-keywords/


djhworld: Draft PR as API docs require updating 
github-actions[bot]: Oops! It looks like no changelog entry is attached to this PR. Please include a release note as described in https://github.com/cloudflare/cloudflare-go/blob/master/docs/changelog-process.md.

Example: 

~~~
```release-note:TYPE
Release note
```
~~~

If you do not require a release note to be included, please add the `workflow/skip-changelog-entry` label.

Patch:
diff --git a/.changelog/1406.txt b/.changelog/1406.txt
new file mode 100644
index 000000000..248b681c5
--- /dev/null
+++ b/.changelog/1406.txt
@@ -0,0 +1,3 @@
+```release-note:enhancement
+api_shield_schema: Add support for managing schemas for API Shield Schema Validation 2.0
+```
diff --git a/api_shield_schemas.go b/api_shield_schemas.go
new file mode 100644
index 000000000..45e557cd5
--- /dev/null
+++ b/api_shield_schemas.go
@@ -0,0 +1,335 @@
+package cloudflare
+
+import (
+	"bytes"
+	"context"
+	"fmt"
+	"io"
+	"mime/multipart"
+	"net/http"
+	"strconv"
+	"time"
+
+	"github.com/goccy/go-json"
+)
+
+// APIShieldSchema represents a schema stored in API Shield Schema Validation 2.0.
+type APIShieldSchema struct {
+	// ID represents the ID of the schema
+	ID string `json:"schema_id"`
+	// Name represents the name of the schema
+	Name string `json:"name"`
+	// Kind of the schema
+	Kind string `json:"kind"`
+	// Source is the contents of the schema
+	Source string `json:"source,omitempty"`
+	// CreatedAt is the time the schema was created
+	CreatedAt *time.Time `json:"created_at,omitempty"`
+	// ValidationEnabled controls if schema is used for validation
+	ValidationEnabled bool `json:"validation_enabled,omitempty"`
+}
+
+// CreateAPIShieldSchemaParams represents the parameters to pass when creating a schema in Schema Validation 2.0.
+//
+// API documentation: https://developers.cloudflare.com/api/operations/api-shield-schema-validation-post-schema
+type CreateAPIShieldSchemaParams struct {
+	// Source is a io.Reader containing the contents of the schema
+	Source io.Reader
+	// Name represents the name of the schema.
+	Name string
+	// Kind of the schema. This is always set to openapi_v3.
+	Kind string
+	// ValidationEnabled controls if schema is used for validation
+	ValidationEnabled *bool
+}
+
+// GetAPIShieldSchemaParams represents the parameters to pass when retrieving a schema with a given schema ID.
+//
+// API documentation: https://developers.cloudflare.com/api/operations/api-shield-schema-validation-retrieve-information-about-specific-schema
+type GetAPIShieldSchemaParams struct {
+	// SchemaID is the ID of the schema to retrieve
+	SchemaID string `url:"-"`
+
+	// OmitSource specifies whether the contents of the schema should be returned in the "Source" field.
+	OmitSource *bool `url:"omit_source,omitempty"`
+}
+
+// ListAPIShieldSchemasParams represents the parameters to pass when retrieving all schemas.
+//
+// API documentation: https://developers.cloudflare.com/api/operations/api-shield-schema-validation-retrieve-information-about-all-schemas
+type ListAPIShieldSchemasParams struct {
+	// OmitSource specifies whether the contents of the schema should be returned in the "Source" field.
+	OmitSource *bool `url:"omit_source,omitempty"`
+
+	// ValidationEnabled specifies whether to return only schemas that have validation enabled.
+	ValidationEnabled *bool `url:"validation_enabled,omitempty"`
+
+	// PaginationOptions to apply to the request.
+	PaginationOptions
+}
+
+// DeleteAPIShieldSchemaParams represents the parameters to pass to delete a schema.
+//
+// API documentation: https://developers.cloudflare.com/api/operations/api-shield-schema-delete-a-schema
+type DeleteAPIShieldSchemaParams struct {
+	// SchemaID is the schema to be deleted
+	SchemaID string `url:"-"`
+}
+
+// UpdateAPIShieldSchemaParams represents the parameters to pass to patch certain fields on an existing schema
+//
+// API documentation: https://developers.cloudflare.com/api/operations/api-shield-schema-validation-enable-validation-for-a-schema
+type UpdateAPIShieldSchemaParams struct {
+	// SchemaID is the schema to be patched
+	SchemaID string `json:"-" url:"-"`
+
+	// ValidationEnabled controls if schema is used for validation
+	ValidationEnabled *bool `json:"validation_enabled" url:"-"`
+}
+
+// APIShieldGetSchemaResponse represents the response from the GET api_gateway/user_schemas/{id} endpoint.
+type APIShieldGetSchemaResponse struct {
+	Result APIShieldSchema `json:"result"`
+	Response
+}
+
+// APIShieldListSchemasResponse represents the response from the GET api_gateway/user_schemas endpoint.
+type APIShieldListSchemasResponse struct {
+	Result     []APIShieldSchema `json:"result"`
+	ResultInfo `json:"result_info"`
+	Response
+}
+
+// APIShieldCreateSchemaResponse represents the response from the POST api_gateway/user_schemas endpoint.
+type APIShieldCreateSchemaResponse struct {
+	Result APIShieldCreateSchemaResult `json:"result"`
+	Response
+}
+
+// APIShieldDeleteSchemaResponse represents the response from the DELETE api_gateway/user_schemas/{id} endpoint.
+type APIShieldDeleteSchemaResponse struct {
+	Result interface{} `json:"result"`
+	Response
+}
+
+// APIShieldPatchSchemaResponse represents the response from the PATCH api_gateway/user_schemas/{id} endpoint.
+type APIShieldPatchSchemaResponse struct {
+	Result APIShieldSchema `json:"result"`
+	Response
+}
+
+// APIShieldCreateSchemaResult represents the successful result of creating a schema in Schema Validation 2.0.
+type APIShieldCreateSchemaResult struct {
+	// APIShieldSchema is the schema that was created
+	Schema APIShieldSchema `json:"schema"`
+	// APIShieldCreateSchemaEvents are non-critical event logs that occurred during processing.
+	Events APIShieldCreateSchemaEvents `json:"upload_details"`
+}
+
+// APIShieldCreateSchemaEvents are event logs that occurred during processing.
+//
+// The logs are separated into levels of severity.
+type APIShieldCreateSchemaEvents struct {
+	Critical *APIShieldCreateSchemaEventWithLocation   `json:"critical,omitempty"`
+	Errors   []APIShieldCreateSchemaEventWithLocations `json:"errors,omitempty"`
+	Warnings []APIShieldCreateSchemaEventWithLocations `json:"warnings,omitempty"`
+}
+
+// APIShieldCreateSchemaEvent is an event log that occurred during processing.
+type APIShieldCreateSchemaEvent struct {
+	// Code identifies the event that occurred
+	Code uint `json:"code"`
+	// Message describes the event that occurred
+	Message string `json:"message"`
+}
+
+// APIShieldCreateSchemaEventWithLocation is an event log that occurred during processing, with the location
+// in the schema where the event occurred.
+type APIShieldCreateSchemaEventWithLocation struct {
+	APIShieldCreateSchemaEvent
+
+	// Location is where the event occurred
+	// See https://goessner.net/articles/JsonPath/ for JSONPath specification.
+	Location string `json:"location,omitempty"`
+}
+
+// APIShieldCreateSchemaEventWithLocations is an event log that occurred during processing, with the location(s)
+// in the schema where the event occurred.
+type APIShieldCreateSchemaEventWithLocations struct {
+	APIShieldCreateSchemaEvent
+
+	// Locations lists JSONPath locations where the event occurred
+	// See https://goessner.net/articles/JsonPath/ for JSONPath specification
+	Locations []string `json:"locations"`
+}
+
+func (cse APIShieldCreateSchemaEventWithLocations) String() string {
+	var s string
+	s += cse.Message
+
+	if len(cse.Locations) == 0 || (len(cse.Locations) == 1 && cse.Locations[0] == "") {
+		// append nothing
+	} else if len(cse.Locations) == 1 {
+		s += fmt.Sprintf(" (%s)", cse.Locations[0])
+	} else {
+		s += " (multiple locations)"
+	}
+
+	return s
+}
+
+// GetAPIShieldSchema retrieves information about a specific schema on a zone
+//
+// API documentation: https://developers.cloudflare.com/api/operations/api-shield-schema-validation-retrieve-information-about-specific-schema
+func (api *API) GetAPIShieldSchema(ctx context.Context, rc *ResourceContainer, params GetAPIShieldSchemaParams) (*APIShieldSchema, error) {
+	if params.SchemaID == "" {
+		return nil, fmt.Errorf("schema ID must be provided")
+	}
+
+	path := fmt.Sprintf("/zones/%s/api_gateway/user_schemas/%s", rc.Identifier, params.SchemaID)
+
+	uri := buildURI(path, params)
+
+	res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil)
+	if err != nil {
+		return nil, err
+	}
+
+	var asResponse APIShieldGetSchemaResponse
+	err = json.Unmarshal(res, &asResponse)
+	if err != nil {
+		return nil, fmt.Errorf("%s: %w", errUnmarshalError, err)
+	}
+
+	return &asResponse.Result, nil
+}
+
+// ListAPIShieldSchemas retrieves all schemas for a zone
+//
+// API documentation: https://developers.cloudflare.com/api/operations/api-shield-schema-validation-retrieve-information-about-all-schemas
+func (api *API) ListAPIShieldSchemas(ctx context.Context, rc *ResourceContainer, params ListAPIShieldSchemasParams) ([]APIShieldSchema, ResultInfo, error) {
+	path := fmt.Sprintf("/zones/%s/api_gateway/user_schemas", rc.Identifier)
+
+	uri := buildURI(path, params)
+
+	res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil)
+	if err != nil {
+		return nil, ResultInfo{}, err
+	}
+
+	var asResponse APIShieldListSchemasResponse
+	err = json.Unmarshal(res, &asResponse)
+	if err != nil {
+		return nil, ResultInfo{}, fmt.Errorf("%s: %w", errUnmarshalError, err)
+	}
+
+	return asResponse.Result, asResponse.ResultInfo, nil
+}
+
+// CreateAPIShieldSchema uploads a schema to a zone
+//
+// API documentation: https://developers.cloudflare.com/api/operations/api-shield-schema-validation-post-schema
+func (api *API) CreateAPIShieldSchema(ctx context.Context, rc *ResourceContainer, params CreateAPIShieldSchemaParams) (*APIShieldCreateSchemaResult, error) {
+	uri := fmt.Sprintf("/zones/%s/api_gateway/user_schemas", rc.Identifier)
+
+	if params.Name == "" {
+		return nil, fmt.Errorf("name must not be empty")
+	}
+
+	if params.Source == nil {
+		return nil, fmt.Errorf("source must not be nil")
+	}
+
+	// Prepare the form to be submitted
+	var b bytes.Buffer
+	w := multipart.NewWriter(&b)
+	// write fields
+	if err := w.WriteField("name", params.Name); err != nil {
+		return nil, fmt.Errorf("error during multi-part form construction: %w", err)
+	}
+	if err := w.WriteField("kind", params.Kind); err != nil {
+		return nil, fmt.Errorf("error during multi-part form construction: %w", err)
+	}
+
+	if params.ValidationEnabled != nil {
+		if err := w.WriteField("validation_enabled", strconv.FormatBool(*params.ValidationEnabled)); err != nil {
+			return nil, fmt.Errorf("error during multi-part form construction: %w", err)
+		}
+	}
+
+	// write schema contents
+	part, err := w.CreateFormFile("file", params.Name)
+	if err != nil {
+		return nil, fmt.Errorf("error during multi-part form construction: %w", err)
+	}
+	if _, err := io.Copy(part, params.Source); err != nil {
+		return nil, fmt.Errorf("error during multi-part form construction: %w", err)
+	}
+	if err := w.Close(); err != nil {
+		return nil, fmt.Errorf("error during multi-part form construction: %w", err)
+	}
+
+	res, err := api.makeRequestContextWithHeaders(ctx, http.MethodPost, uri, &b, http.Header{
+		"Content-Type": []string{w.FormDataContentType()},
+	})
+	if err != nil {
+		return nil, err
+	}
+
+	var asResponse APIShieldCreateSchemaResponse
+	err = json.Unmarshal(res, &asResponse)
+	if err != nil {
+		return nil, fmt.Errorf("%s: %w", errUnmarshalError, err)
+	}
+
+	return &asResponse.Result, nil
+}
+
+// DeleteAPIShieldSchema deletes a single schema
+//
+// API documentation: https://developers.cloudflare.com/api/operations/api-shield-schema-delete-a-schema
+func (api *API) DeleteAPIShieldSchema(ctx context.Context, rc *ResourceContainer, params DeleteAPIShieldSchemaParams) error {
+	if params.SchemaID == "" {
+		return fmt.Errorf("schema ID must be provided")
+	}
+
+	uri := fmt.Sprintf("/zones/%s/api_gateway/user_schemas/%s", rc.Identifier, params.SchemaID)
+
+	res, err := api.makeRequestContext(ctx, http.MethodDelete, uri, nil)
+	if err != nil {
+		return err
+	}
+
+	var asResponse APIShieldDeleteSchemaResponse
+	err = json.Unmarshal(res, &asResponse)
+	if err != nil {
+		return fmt.Errorf("%s: %w", errUnmarshalError, err)
+	}
+
+	return nil
+}
+
+// UpdateAPIShieldSchema updates certain fields on an existing schema.
+//
+// API documentation: https://developers.cloudflare.com/api/operations/api-shield-schema-validation-enable-validation-for-a-schema
+func (api *API) UpdateAPIShieldSchema(ctx context.Context, rc *ResourceContainer, params UpdateAPIShieldSchemaParams) (*APIShieldSchema, error) {
+	if params.SchemaID == "" {
+		return nil, fmt.Errorf("schema ID must be provided")
+	}
+
+	uri := fmt.Sprintf("/zones/%s/api_gateway/user_schemas/%s", rc.Identifier, params.SchemaID)
+
+	res, err := api.makeRequestContext(ctx, http.MethodPatch, uri, params)
+	if err != nil {
+		return nil, err
+	}
+
+	// Result should be the updated schema that was patched
+	var asResponse APIShieldPatchSchemaResponse
+	err = json.Unmarshal(res, &asResponse)
+	if err != nil {
+		return nil, fmt.Errorf("%s: %w", errUnmarshalError, err)
+	}
+
+	return &asResponse.Result, nil
+}


Test patch:
diff --git a/api_shield_schemas_test.go b/api_shield_schemas_test.go
new file mode 100644
index 000000000..5d2f21990
--- /dev/null
+++ b/api_shield_schemas_test.go
@@ -0,0 +1,504 @@
+package cloudflare
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"io"
+	"mime"
+	"mime/multipart"
+	"net/http"
+	"net/url"
+	"strings"
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+const testAPIShieldSchemaId = "0f05e4fc-1c36-4bd2-a5e7-b29d32d3dc8e"
+
+func TestGetAPIShieldSchema(t *testing.T) {
+	endpoint := fmt.Sprintf("/zones/%s/api_gateway/user_schemas/%s", testZoneID, testAPIShieldSchemaId)
+	response := `{
+		"success" : true,
+		"errors": [],
+		"messages": [],
+		"result": {
+			"schema_id": "0f05e4fc-1c36-4bd2-a5e7-b29d32d3dc8e",
+			"name": "petstore.json",
+			"kind": "openapi_v3",
+			"created_at": "2023-03-02T15:46:06.000000Z",
+			"validation_enabled": true,
+			"source": "{}"
+		}
+	}`
+
+	tests := []struct {
+		name           string
+		getParams      GetAPIShieldSchemaParams
+		expectedParams url.Values
+	}{
+		{
+			name: "without omit source",
+			getParams: GetAPIShieldSchemaParams{
+				SchemaID: testAPIShieldSchemaId,
+			},
+			expectedParams: url.Values{},
+		},
+		{
+			name: "with omit source=true",
+			getParams: GetAPIShieldSchemaParams{
+				SchemaID:   testAPIShieldSchemaId,
+				OmitSource: BoolPtr(true),
+			},
+			expectedParams: url.Values{
+				"omit_source": []string{"true"},
+			},
+		},
+		{
+			name: "with omit source=false",
+			getParams: GetAPIShieldSchemaParams{
+				SchemaID:   testAPIShieldSchemaId,
+				OmitSource: BoolPtr(false),
+			},
+			expectedParams: url.Values{
+				"omit_source": []string{"false"},
+			},
+		},
+	}
+
+	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.GetAPIShieldSchema(
+				context.Background(),
+				ZoneIdentifier(testZoneID),
+				test.getParams,
+			)
+
+			createdAt := time.Date(2023, time.March, 2, 15, 46, 6, 0, time.UTC)
+			expected := &APIShieldSchema{
+				ID:                testAPIShieldSchemaId,
+				Name:              "petstore.json",
+				Kind:              "openapi_v3",
+				Source:            "{}",
+				CreatedAt:         &createdAt,
+				ValidationEnabled: true,
+			}
+
+			if assert.NoError(t, err) {
+				assert.Equal(t, expected, actual)
+			}
+		})
+	}
+}
+
+func TestListAPIShieldSchemas(t *testing.T) {
+	endpoint := fmt.Sprintf("/zones/%s/api_gateway/user_schemas", testZoneID)
+	response := `{
+		"success" : true,
+		"errors": [],
+		"messages": [],
+		"result": [
+			{
+				"schema_id": "0f05e4fc-1c36-4bd2-a5e7-b29d32d3dc8e",
+				"name": "petstore.json",
+				"kind": "openapi_v3",
+				"created_at": "2023-03-02T15:46:06.000000Z",
+				"validation_enabled": true,
+				"source": "{}"
+			}
+		],
+		"result_info": {
+			"page": 3,
+			"per_page": 20,
+			"count": 1,
+			"total_count": 2000
+		}
+	}`
+
+	tests := []struct {
+		name           string
+		getParams      ListAPIShieldSchemasParams
+		expectedParams url.Values
+	}{
+		{
+			name:           "with empty params",
+			getParams:      ListAPIShieldSchemasParams{},
+			expectedParams: url.Values{},
+		},
+		{
+			name: "all params",
+			getParams: ListAPIShieldSchemasParams{
+				OmitSource:        BoolPtr(true),
+				ValidationEnabled: BoolPtr(false),
+				PaginationOptions: PaginationOptions{
+					Page:    1,
+					PerPage: 25,
+				},
+			},
+			expectedParams: url.Values{
+				"omit_source":        []string{"true"},
+				"validation_enabled": []string{"false"},
+				"page":               []string{"1"},
+				"per_page":           []string{"25"},
+			},
+		},
+		{
+			name: "with omit source",
+			getParams: ListAPIShieldSchemasParams{
+				OmitSource: BoolPtr(true),
+			},
+			expectedParams: url.Values{
+				"omit_source": []string{"true"},
+			},
+		},
+		{
+			name: "with validation enabled",
+			getParams: ListAPIShieldSchemasParams{
+				ValidationEnabled: BoolPtr(true),
+			},
+			expectedParams: url.Values{
+				"validation_enabled": []string{"true"},
+			},
+		},
+	}
+
+	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, resultInfo, err := client.ListAPIShieldSchemas(
+				context.Background(),
+				ZoneIdentifier(testZoneID),
+				test.getParams,
+			)
+
+			createdAt := time.Date(2023, time.March, 2, 15, 46, 6, 0, time.UTC)
+			expected := []APIShieldSchema{
+				{
+					ID:                testAPIShieldSchemaId,
+					Name:              "petstore.json",
+					Kind:              "openapi_v3",
+					Source:            "{}",
+					CreatedAt:         &createdAt,
+					ValidationEnabled: true,
+				},
+			}
+
+			if assert.NoError(t, err) {
+				assert.Equal(t, expected, actual)
+
+				expectedResultInfo := ResultInfo{
+					Page:    3,
+					PerPage: 20,
+					Count:   1,
+					Total:   2000,
+				}
+				assert.Equal(t, expectedResultInfo, resultInfo)
+			}
+		})
+	}
+}
+
+func TestDeleteAPIShieldSchema(t *testing.T) {
+	setup()
+	t.Cleanup(teardown)
+
+	endpoint := fmt.Sprintf("/zones/%s/api_gateway/user_schemas/%s", testZoneID, testAPIShieldSchemaId)
+	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.DeleteAPIShieldSchema(
+		context.Background(),
+		ZoneIdentifier(testZoneID),
+		DeleteAPIShieldSchemaParams{
+			SchemaID: testAPIShieldSchemaId,
+		},
+	)
+
+	assert.NoError(t, err)
+}
+
+func TestUpdateAPIShieldSchema(t *testing.T) {
+	setup()
+	t.Cleanup(teardown)
+
+	endpoint := fmt.Sprintf("/zones/%s/api_gateway/user_schemas/%s", testZoneID, testAPIShieldSchemaId)
+	response := `{
+		"success" : true,
+		"errors": [],
+		"messages": [],
+		"result": {
+			"schema_id": "0f05e4fc-1c36-4bd2-a5e7-b29d32d3dc8e",
+			"name": "petstore.json",
+			"kind": "openapi_v3",
+			"validation_enabled": true
+		}
+	}`
+
+	handler := func(w http.ResponseWriter, r *http.Request) {
+		require.Equal(t, http.MethodPatch, r.Method, "Expected method 'PATCH', got %s", r.Method)
+		require.Empty(t, r.URL.Query())
+
+		body, err := io.ReadAll(r.Body)
+		require.NoError(t, err)
+		require.Equal(t, `{"validation_enabled":true}`, string(body))
+
+		w.Header().Set("content-type", "application/json")
+		fmt.Fprint(w, response)
+	}
+
+	mux.HandleFunc(endpoint, handler)
+
+	actual, err := client.UpdateAPIShieldSchema(
+		context.Background(),
+		ZoneIdentifier(testZoneID),
+		UpdateAPIShieldSchemaParams{
+			SchemaID:          testAPIShieldSchemaId,
+			ValidationEnabled: BoolPtr(true),
+		},
+	)
+
+	// patch result is a cut down representation of the schema
+	// so metadata like created date is not populated
+	expected := &APIShieldSchema{
+		ID:                testAPIShieldSchemaId,
+		Name:              "petstore.json",
+		Kind:              "openapi_v3",
+		ValidationEnabled: true,
+	}
+
+	if assert.NoError(t, err) {
+		assert.Equal(t, expected, actual)
+	}
+
+	assert.NoError(t, err)
+}
+
+func TestCreateAPIShieldSchema(t *testing.T) {
+	setup()
+	t.Cleanup(teardown)
+
+	endpoint := fmt.Sprintf("/zones/%s/api_gateway/user_schemas", testZoneID)
+	response := `{
+		"success" : true,
+		"errors": [],
+		"messages": [],
+		"result": {
+			"schema": {
+				"schema_id": "0f05e4fc-1c36-4bd2-a5e7-b29d32d3dc8e",
+				"name": "petstore.json",
+				"kind": "openapi_v3",
+				"created_at": "2023-03-02T15:46:06.000000Z",
+				"validation_enabled": true,
+				"source": "{}"
+			},
+			"upload_details": {
+				"warnings": [
+					{
+						"code": 28,
+						"message": "unsupported media type: application/octet-stream",
+						"locations": [
+							".paths[\"/user/{username}\"].put"
+						]
+					}
+				]
+			}
+		}
+	}`
+
+	handler := func(w http.ResponseWriter, r *http.Request) {
+		require.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method)
+
+		// Check that form data has been sent correctly.
+		mediaType, params, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
+		require.NoError(t, err)
+		require.True(t, strings.HasPrefix(mediaType, "multipart/form-data"))
+
+		expectedParts := []struct {
+			partKeys map[string]string
+			data     []byte
+		}{
+			{
+				partKeys: map[string]string{"name": "name"},
+				data:     []byte("petstore.json"),
+			},
+			{
+				partKeys: map[string]string{"name": "kind"},
+				data:     []byte("openapi_v3"),
+			},
+			{
+				partKeys: map[string]string{"name": "validation_enabled"},
+				data:     []byte("true"),
+			},
+			{
+				partKeys: map[string]string{"filename": "petstore.json", "name": "file"},
+				data:     []byte("{}"),
+			},
+		}
+
+		multiPartReader := multipart.NewReader(r.Body, params["boundary"])
+		partNum := 0
+		for {
+			part, err := multiPartReader.NextPart()
+			if errors.Is(err, io.EOF) {
+				break
+			}
+			require.NoError(t, err)
+
+			// more parts found - this is unexpected
+			require.Less(t, partNum, len(expectedParts))
+
+			value, err := io.ReadAll(part)
+			require.NoError(t, err)
+
+			_, dParams, err := mime.ParseMediaType(part.Header.Get("Content-Disposition"))
+			require.NoError(t, err)
+
+			require.Equal(t, expectedParts[partNum].partKeys, dParams)
+			require.Equal(t, expectedParts[partNum].data, value)
+			partNum++
+		}
+
+		w.Header().Set("content-type", "application/json")
+		fmt.Fprint(w, response)
+	}
+
+	mux.HandleFunc(endpoint, handler)
+
+	actual, err := client.CreateAPIShieldSchema(
+		context.Background(),
+		ZoneIdentifier(testZoneID),
+		CreateAPIShieldSchemaParams{
+			Name:              "petstore.json",
+			Kind:              "openapi_v3",
+			Source:            strings.NewReader("{}"),
+			ValidationEnabled: BoolPtr(true),
+		},
+	)
+
+	createdAt := time.Date(2023, time.March, 2, 15, 46, 6, 0, time.UTC)
+	expected := &APIShieldCreateSchemaResult{
+		Schema: APIShieldSchema{
+			ID:                testAPIShieldSchemaId,
+			Name:              "petstore.json",
+			Kind:              "openapi_v3",
+			Source:            "{}",
+			CreatedAt:         &createdAt,
+			ValidationEnabled: true,
+		},
+		Events: APIShieldCreateSchemaEvents{
+			Warnings: []APIShieldCreateSchemaEventWithLocations{
+				{
+					APIShieldCreateSchemaEvent: APIShieldCreateSchemaEvent{
+						Code:    28,
+						Message: "unsupported media type: application/octet-stream",
+					},
+					Locations: []string{
+						".paths[\"/user/{username}\"].put",
+					},
+				},
+			},
+		},
+	}
+
+	if assert.NoError(t, err) {
+		assert.Equal(t, expected, actual)
+	}
+}
+
+func TestCreateAPIShieldSchemaError(t *testing.T) {
+	setup()
+	t.Cleanup(teardown)
+
+	endpoint := fmt.Sprintf("/zones/%s/api_gateway/user_schemas", testZoneID)
+	response := `{
+		"success" : false,
+		"errors": [
+			{
+			  "code": 21400,
+			  "message": "only default parameter styles are supported: path, form (.paths[\"/pet/{petId}\"].get.parameters[0])"
+			}
+		],
+		"messages": [],
+		"result": {
+			"upload_details": {
+				"errors": [
+					{
+						"code": 22,
+						"message": "only default parameter styles are supported: path, form",
+						"locations": [
+							".paths[\"/pet/{petId}\"].get.parameters[0]"
+						]
+					}
+				]
+			}
+		}
+	}`
+
+	handler := func(w http.ResponseWriter, r *http.Request) {
+		require.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method)
+		w.Header().Set("content-type", "application/json")
+		w.WriteHeader(400)
+		fmt.Fprint(w, response)
+	}
+
+	mux.HandleFunc(endpoint, handler)
+
+	_, err := client.CreateAPIShieldSchema(
+		context.Background(),
+		ZoneIdentifier(testZoneID),
+		CreateAPIShieldSchemaParams{
+			Name:              "petstore.json",
+			Kind:              "openapi_v3",
+			Source:            strings.NewReader("{}"),
+			ValidationEnabled: BoolPtr(true),
+		},
+	)
+
+	require.ErrorContains(t, err, "only default parameter styles are supported: path, form (.paths[\"/pet/{petId}\"].get.parameters[0]) (21400)")
+}
+
+func TestMustProvideSchemaID(t *testing.T) {
+	setup()
+	t.Cleanup(teardown)
+
+	_, err := client.GetAPIShieldSchema(context.Background(), ZoneIdentifier(testZoneID), GetAPIShieldSchemaParams{})
+	require.ErrorContains(t, err, "schema ID must be provided")
+
+	_, err = client.UpdateAPIShieldSchema(context.Background(), ZoneIdentifier(testZoneID), UpdateAPIShieldSchemaParams{})
+	require.ErrorContains(t, err, "schema ID must be provided")
+
+	err = client.DeleteAPIShieldSchema(context.Background(), ZoneIdentifier(testZoneID), DeleteAPIShieldSchemaParams{})
+	require.ErrorContains(t, err, "schema ID must be provided")
+}