From 944ce59cea6935be0c4d819da7559fdf682dbbc6 Mon Sep 17 00:00:00 2001 From: Daniel Harper <529730+djhworld@users.noreply.github.com> Date: Thu, 21 Sep 2023 09:56:08 +0100 Subject: [PATCH] APISHI-2358 resource/cloudflare_api_shield_schema: add API Shield Schema resource --- .changelog/2784.txt | 3 + docs/resources/api_shield_schema.md | 41 +++ .../cloudflare_api_shield_schema/resource.tf | 7 + internal/sdkv2provider/provider.go | 1 + .../resource_cloudflare_api_shield_schema.go | 134 +++++++++ ...ource_cloudflare_api_shield_schema_test.go | 258 ++++++++++++++++++ .../schema_cloudflare_api_shield_schema.go | 42 +++ 7 files changed, 486 insertions(+) create mode 100644 .changelog/2784.txt create mode 100644 docs/resources/api_shield_schema.md create mode 100644 examples/resources/cloudflare_api_shield_schema/resource.tf create mode 100644 internal/sdkv2provider/resource_cloudflare_api_shield_schema.go create mode 100644 internal/sdkv2provider/resource_cloudflare_api_shield_schema_test.go create mode 100644 internal/sdkv2provider/schema_cloudflare_api_shield_schema.go diff --git a/.changelog/2784.txt b/.changelog/2784.txt new file mode 100644 index 0000000000..6becc387a3 --- /dev/null +++ b/.changelog/2784.txt @@ -0,0 +1,3 @@ +```release-note:new-resource +cloudflare_api_shield_schema +``` diff --git a/docs/resources/api_shield_schema.md b/docs/resources/api_shield_schema.md new file mode 100644 index 0000000000..0291ee8305 --- /dev/null +++ b/docs/resources/api_shield_schema.md @@ -0,0 +1,41 @@ +--- +page_title: "cloudflare_api_shield_schema Resource - Cloudflare" +subcategory: "" +description: |- + Provides a resource to manage a schema in API Shield Schema Validation 2.0. +--- + +# cloudflare_api_shield_schema (Resource) + +Provides a resource to manage a schema in API Shield Schema Validation 2.0. + +## Example Usage + +```terraform +resource "cloudflare_api_shield_schema" "petstore_schema" { + zone_id = "0da42c8d2132a9ddaf714f9e7c920711" + name = "myschema" + kind = "openapi_v3" # optional + validation_enabled = true # optional, default false + source = file("./schemas/petstore.json") +} +``` + +## Schema + +### Required + +- `name` (String) Name of the schema. **Modifying this attribute will force creation of a new resource.** +- `source` (String) Schema file bytes. **Modifying this attribute will force creation of a new resource.** +- `zone_id` (String) The zone identifier to target for the resource. **Modifying this attribute will force creation of a new resource.** + +### Optional + +- `kind` (String) Kind of schema. Defaults to `openapi_v3`. **Modifying this attribute will force creation of a new resource.** +- `validation_enabled` (Boolean) Flag whether schema is enabled for validation. + +### Read-Only + +- `id` (String) The ID of this resource. + + diff --git a/examples/resources/cloudflare_api_shield_schema/resource.tf b/examples/resources/cloudflare_api_shield_schema/resource.tf new file mode 100644 index 0000000000..43a365cd6b --- /dev/null +++ b/examples/resources/cloudflare_api_shield_schema/resource.tf @@ -0,0 +1,7 @@ +resource "cloudflare_api_shield_schema" "petstore_schema" { + zone_id = "0da42c8d2132a9ddaf714f9e7c920711" + name = "myschema" + kind = "openapi_v3" # optional + validation_enabled = true # optional, default false + source = file("./schemas/petstore.json") +} diff --git a/internal/sdkv2provider/provider.go b/internal/sdkv2provider/provider.go index 3d49aff75c..19023f57bd 100644 --- a/internal/sdkv2provider/provider.go +++ b/internal/sdkv2provider/provider.go @@ -195,6 +195,7 @@ func New(version string) func() *schema.Provider { "cloudflare_address_map": resourceCloudflareAddressMap(), "cloudflare_api_shield": resourceCloudflareAPIShield(), "cloudflare_api_shield_operation": resourceCloudflareAPIShieldOperation(), + "cloudflare_api_shield_schema": resourceCloudflareAPIShieldSchemas(), "cloudflare_api_token": resourceCloudflareApiToken(), "cloudflare_argo": resourceCloudflareArgo(), "cloudflare_authenticated_origin_pulls_certificate": resourceCloudflareAuthenticatedOriginPullsCertificate(), diff --git a/internal/sdkv2provider/resource_cloudflare_api_shield_schema.go b/internal/sdkv2provider/resource_cloudflare_api_shield_schema.go new file mode 100644 index 0000000000..b215b467eb --- /dev/null +++ b/internal/sdkv2provider/resource_cloudflare_api_shield_schema.go @@ -0,0 +1,134 @@ +package sdkv2provider + +import ( + "context" + "fmt" + "strings" + + "github.com/MakeNowJust/heredoc/v2" + "github.com/cloudflare/cloudflare-go" + "github.com/cloudflare/terraform-provider-cloudflare/internal/consts" + "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/pkg/errors" +) + +func resourceCloudflareAPIShieldSchemas() *schema.Resource { + return &schema.Resource{ + Schema: resourceCloudflareAPIShieldSchemaSchema(), + CreateContext: resourceCloudflareAPIShieldSchemaCreate, + ReadContext: resourceCloudflareAPIShieldSchemaRead, + DeleteContext: resourceCloudflareAPIShieldSchemaDelete, + UpdateContext: resourceCloudflareAPIShieldSchemaUpdate, + Importer: &schema.ResourceImporter{ + StateContext: nil, + }, + Description: heredoc.Doc(` + Provides a resource to manage a schema in API Shield Schema Validation 2.0. + `), + } +} + +func resourceCloudflareAPIShieldSchemaCreate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + client := meta.(*cloudflare.API) + zoneID := d.Get(consts.ZoneIDSchemaKey).(string) + + sch, err := client.CreateAPIShieldSchema( + ctx, + cloudflare.ZoneIdentifier(zoneID), + cloudflare.CreateAPIShieldSchemaParams{ + Name: d.Get("name").(string), + Kind: d.Get("kind").(string), + Source: strings.NewReader(d.Get("source").(string)), + ValidationEnabled: cloudflare.BoolPtr(d.Get("validation_enabled").(bool)), + }, + ) + + if err != nil { + return diag.FromErr(errors.Wrap(err, "failed to create cloudflare_api_shield_schema")) + } + + // log warnings that occurred during creation + for _, w := range sch.Events.Warnings { + tflog.Warn(ctx, fmt.Sprintf("cloudflare_api_shield_schema: warning encountered when creating schema: %s", w)) + } + + d.SetId(sch.Schema.ID) + + return resourceCloudflareAPIShieldSchemaRead(ctx, d, meta) +} + +func resourceCloudflareAPIShieldSchemaRead(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + client := meta.(*cloudflare.API) + zoneID := d.Get(consts.ZoneIDSchemaKey).(string) + + sch, err := client.GetAPIShieldSchema( + ctx, + cloudflare.ZoneIdentifier(zoneID), + cloudflare.GetAPIShieldSchemaParams{ + SchemaID: d.Id(), + }, + ) + + if err != nil { + return diag.FromErr(fmt.Errorf("failed to fetch API Shield Schema: %w", err)) + } + + if err := d.Set("name", sch.Name); err != nil { + return diag.FromErr(err) + } + + if err := d.Set("kind", sch.Kind); err != nil { + return diag.FromErr(err) + } + + if err := d.Set("source", sch.Source); err != nil { + return diag.FromErr(err) + } + + if err := d.Set("validation_enabled", sch.ValidationEnabled); err != nil { + return diag.FromErr(err) + } + + d.SetId(sch.ID) + return nil +} + +func resourceCloudflareAPIShieldSchemaUpdate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + client := meta.(*cloudflare.API) + zoneID := d.Get(consts.ZoneIDSchemaKey).(string) + + _, err := client.UpdateAPIShieldSchema( + ctx, + cloudflare.ZoneIdentifier(zoneID), + cloudflare.UpdateAPIShieldSchemaParams{ + SchemaID: d.Id(), + ValidationEnabled: cloudflare.BoolPtr(d.Get("validation_enabled").(bool)), + }, + ) + + if err != nil { + return diag.FromErr(errors.Wrap(err, "failed to create API Shield Schema")) + } + + return resourceCloudflareAPIShieldSchemaRead(ctx, d, meta) +} + +func resourceCloudflareAPIShieldSchemaDelete(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + client := meta.(*cloudflare.API) + zoneID := d.Get(consts.ZoneIDSchemaKey).(string) + + err := client.DeleteAPIShieldSchema( + ctx, + cloudflare.ZoneIdentifier(zoneID), + cloudflare.DeleteAPIShieldSchemaParams{ + SchemaID: d.Id(), + }, + ) + if err != nil { + return diag.FromErr(fmt.Errorf("failed to fetch API Shield Schema: %w", err)) + } + + return nil +} diff --git a/internal/sdkv2provider/resource_cloudflare_api_shield_schema_test.go b/internal/sdkv2provider/resource_cloudflare_api_shield_schema_test.go new file mode 100644 index 0000000000..08d78c50c0 --- /dev/null +++ b/internal/sdkv2provider/resource_cloudflare_api_shield_schema_test.go @@ -0,0 +1,258 @@ +package sdkv2provider + +import ( + "context" + "errors" + "fmt" + "os" + "strings" + "testing" + + "github.com/cloudflare/cloudflare-go" + "github.com/cloudflare/terraform-provider-cloudflare/internal/consts" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/terraform" +) + +func TestAccCloudflareAPIShieldSchema_Create(t *testing.T) { + // Temporarily unset CLOUDFLARE_API_TOKEN if it is set as the API token + // endpoint does not yet support the API tokens without an explicit scope. + if os.Getenv("CLOUDFLARE_API_TOKEN") != "" { + t.Setenv("CLOUDFLARE_API_TOKEN", "") + } + + rnd, rnd2 := generateRandomResourceName(), generateRandomResourceName() + resourceID := "cloudflare_api_shield_schema." + rnd + resourceID2 := "cloudflare_api_shield_schema." + rnd2 + zoneID := os.Getenv("CLOUDFLARE_ZONE_ID") + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProviderFactories: providerFactories, + CheckDestroy: testAccCheckCloudflareAPIShieldSchemasAreDeleted, + Steps: []resource.TestStep{ + { + Config: testAccCloudflareAPIShieldSchema(rnd, zoneID, "myschema", testAPIShieldFixtureSchema), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(resourceID, consts.ZoneIDSchemaKey, zoneID), + resource.TestCheckResourceAttr(resourceID, "name", "myschema"), + resource.TestCheckResourceAttr(resourceID, "kind", "openapi_v3"), + // validation_enabled is not explicitly defined in resource, but should be false + resource.TestCheckResourceAttr(resourceID, "validation_enabled", "false"), + resource.TestCheckResourceAttrWith(resourceID, "source", func(value string) error { + // remove trailing whitespace from template + value = strings.TrimSpace(value) + if value != testAPIShieldFixtureSchema { + return fmt.Errorf("expected source to be: %v but got: %v", testAPIShieldFixtureSchema, value) + } + return nil + }), + ), + }, + // check new resource with different ID (resourceID2) with optional parameter "validation_enabled" set to true + { + Config: testAccCloudflareAPIShieldSchemaValidationEnabled(rnd2, zoneID, "myschema", testAPIShieldFixtureSchema, true), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(resourceID2, consts.ZoneIDSchemaKey, zoneID), + resource.TestCheckResourceAttr(resourceID2, "name", "myschema"), + resource.TestCheckResourceAttr(resourceID2, "kind", "openapi_v3"), + resource.TestCheckResourceAttr(resourceID2, "validation_enabled", "true"), + resource.TestCheckResourceAttrWith(resourceID2, "source", func(value string) error { + // remove trailing whitespace from template + value = strings.TrimSpace(value) + if value != testAPIShieldFixtureSchema { + return fmt.Errorf("expected source to be: %v but got: %v", testAPIShieldFixtureSchema, value) + } + return nil + }), + ), + }, + }, + }) +} + +func TestAccCloudflareAPIShieldSchema_CreateForceNew(t *testing.T) { + // Temporarily unset CLOUDFLARE_API_TOKEN if it is set as the API token + // endpoint does not yet support the API tokens without an explicit scope. + if os.Getenv("CLOUDFLARE_API_TOKEN") != "" { + t.Setenv("CLOUDFLARE_API_TOKEN", "") + } + + rnd := generateRandomResourceName() + resourceID := "cloudflare_api_shield_schema." + rnd + zoneID := os.Getenv("CLOUDFLARE_ZONE_ID") + + var previousSchemaID string + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProviderFactories: providerFactories, + CheckDestroy: testAccCheckCloudflareAPIShieldSchemasAreDeleted, + Steps: []resource.TestStep{ + { + Config: testAccCloudflareAPIShieldSchema(rnd, zoneID, "myschema", testAPIShieldFixtureSchema), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrWith(resourceID, "id", func(value string) error { + previousSchemaID = value + return nil + }), + ), + }, + { + // changing the name should force a new schema + Config: testAccCloudflareAPIShieldSchema(rnd, zoneID, "myschema2", testAPIShieldFixtureSchema), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(resourceID, "name", "myschema2"), + resource.TestCheckResourceAttrWith(resourceID, "id", func(value string) error { + if value == previousSchemaID { + return fmt.Errorf("expected schema ID to have changed") + } + previousSchemaID = value + return nil + }), + ), + }, + { + // changing the source should force a new schema + Config: testAccCloudflareAPIShieldSchema(rnd, zoneID, "myschema2", testAPIShieldFixtureSchema2), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrWith(resourceID, "source", func(value string) error { + // remove trailing whitespace from template + value = strings.TrimSpace(value) + if value != testAPIShieldFixtureSchema2 { + return fmt.Errorf("expected source to be: %v but got: %v", testAPIShieldFixtureSchema2, value) + } + return nil + }), + resource.TestCheckResourceAttrWith(resourceID, "id", func(value string) error { + if value == previousSchemaID { + return fmt.Errorf("expected schema ID to have changed") + } + previousSchemaID = value + return nil + }), + ), + }, + }, + }) +} + +func TestAccCloudflareAPIShieldSchema_Update(t *testing.T) { + // Temporarily unset CLOUDFLARE_API_TOKEN if it is set as the API token + // endpoint does not yet support the API tokens without an explicit scope. + if os.Getenv("CLOUDFLARE_API_TOKEN") != "" { + t.Setenv("CLOUDFLARE_API_TOKEN", "") + } + + rnd := generateRandomResourceName() + resourceID := "cloudflare_api_shield_schema." + rnd + zoneID := os.Getenv("CLOUDFLARE_ZONE_ID") + + var schemaID string + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProviderFactories: providerFactories, + CheckDestroy: testAccCheckCloudflareAPIShieldSchemasAreDeleted, + Steps: []resource.TestStep{ + { + Config: testAccCloudflareAPIShieldSchemaValidationEnabled(rnd, zoneID, "myschema", testAPIShieldFixtureSchema, false), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(resourceID, "validation_enabled", "false"), + resource.TestCheckResourceAttrWith(resourceID, "id", func(value string) error { + schemaID = value + return nil + }), + ), + }, + { + // changing the validation_enabled status to "true" should update the existing schema + Config: testAccCloudflareAPIShieldSchemaValidationEnabled(rnd, zoneID, "myschema", testAPIShieldFixtureSchema, true), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(resourceID, "validation_enabled", "true"), + resource.TestCheckResourceAttrWith(resourceID, "id", func(value string) error { + if value != schemaID { + return fmt.Errorf("expected schema ID to have remained the same") + } + return nil + }), + ), + }, + }, + }) +} + +func testAccCheckCloudflareAPIShieldSchemasAreDeleted(s *terraform.State) error { + client := testAccProvider.Meta().(*cloudflare.API) + + for _, rs := range s.RootModule().Resources { + if rs.Type != "cloudflare_api_shield_schema" { + continue + } + + _, err := client.GetAPIShieldSchema( + context.Background(), + cloudflare.ZoneIdentifier(rs.Primary.Attributes[consts.ZoneIDSchemaKey]), + cloudflare.GetAPIShieldSchemaParams{ + SchemaID: rs.Primary.Attributes["id"], + }, + ) + if err == nil { + return fmt.Errorf("schema still exists") + } + + var notFoundError *cloudflare.NotFoundError + if !errors.As(err, ¬FoundError) { + return fmt.Errorf("expected not found error but got: %w", err) + } + } + + return nil +} + +func testAccCloudflareAPIShieldSchema(resourceName, zone string, name, source string) string { + return fmt.Sprintf(` + resource "cloudflare_api_shield_schema" "%[1]s" { + zone_id = "%[2]s" + name = "%[3]s" + source = <<-EOT +%[4]s +EOT + } +`, resourceName, zone, name, source) +} + +func testAccCloudflareAPIShieldSchemaValidationEnabled(resourceName, zone string, name, source string, validationEnabled bool) string { + return fmt.Sprintf(` + resource "cloudflare_api_shield_schema" "%[1]s" { + zone_id = "%[2]s" + name = "%[3]s" + validation_enabled = %[4]v + source = <<-EOT +%[5]s +EOT + } +`, resourceName, zone, name, validationEnabled, source) +} + +const testAPIShieldFixtureSchema = `{ + "openapi": "3.0.2", + "servers": [ + { + "url": "https://example.com" + } + ], + "paths": { + "/example/path": {} + } +}` + +const testAPIShieldFixtureSchema2 = `{ + "openapi": "3.0.2", + "servers": [ + { + "url": "https://developers.example.com" + } + ], + "paths": { + "/example/path": {} + } +}` diff --git a/internal/sdkv2provider/schema_cloudflare_api_shield_schema.go b/internal/sdkv2provider/schema_cloudflare_api_shield_schema.go new file mode 100644 index 0000000000..e1a1be7bbe --- /dev/null +++ b/internal/sdkv2provider/schema_cloudflare_api_shield_schema.go @@ -0,0 +1,42 @@ +package sdkv2provider + +import ( + "github.com/cloudflare/terraform-provider-cloudflare/internal/consts" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +// API Shield Schema Terraform Schema. +func resourceCloudflareAPIShieldSchemaSchema() map[string]*schema.Schema { + return map[string]*schema.Schema{ + consts.ZoneIDSchemaKey: { + Description: consts.ZoneIDSchemaDescription, + Type: schema.TypeString, + Required: true, + ForceNew: true, + }, + "name": { + Description: "Name of the schema", + Type: schema.TypeString, + Required: true, + ForceNew: true, + }, + "kind": { + Description: "Kind of schema", + Type: schema.TypeString, + Optional: true, + ForceNew: true, + Default: "openapi_v3", + }, + "source": { + Description: "Schema file bytes", + Type: schema.TypeString, + Required: true, + ForceNew: true, + }, + "validation_enabled": { + Description: "Flag whether schema is enabled for validation", + Type: schema.TypeBool, + Optional: true, + }, + } +}