From 7bd1e57cd664f60c98c6e618880b2f74e6a58d12 Mon Sep 17 00:00:00 2001 From: "alvarez.mauriciotm@gmail.com" Date: Thu, 30 Jul 2026 13:48:27 -0700 Subject: [PATCH 1/2] ListResponseArray logic --- mmv1/api/resource.go | 5 ++ mmv1/products/apigee/AddonsConfig.yaml | 4 + mmv1/products/apigee/ApiProduct.yaml | 3 + .../apigee/EnvironmentKeyvaluemaps.yaml | 3 + mmv1/products/apigee/Organization.yaml | 1 + mmv1/products/apigee/TargetServer.yaml | 3 + .../decoders/apigee_addons_config.go.tmpl | 9 ++ .../decoders/apigee_organization.go.tmpl | 9 ++ .../terraform/list_resource_method.go.tmpl | 10 +++ .../terraform/transport/transport.go | 90 +++++++++++++++++++ 10 files changed, 137 insertions(+) create mode 100644 mmv1/templates/terraform/decoders/apigee_addons_config.go.tmpl create mode 100644 mmv1/templates/terraform/decoders/apigee_organization.go.tmpl diff --git a/mmv1/api/resource.go b/mmv1/api/resource.go index 92f413a8931b..1c50869a8b43 100644 --- a/mmv1/api/resource.go +++ b/mmv1/api/resource.go @@ -258,6 +258,11 @@ type Resource struct { // types that share the same API URL (e.g. engines filtered by solutionType). ListFilter string `yaml:"list_filter,omitempty"` + // [Optional] If true, the list API response is a bare JSON array instead of + // a wrapped object with a named key. Use ListArrayPages instead of ListPages + // when generating the list function. + ListResponseIsArray bool `yaml:"list_response_is_array,omitempty"` + // If true, skip sweeper generation for this resource ExcludeSweeper bool `yaml:"exclude_sweeper,omitempty"` diff --git a/mmv1/products/apigee/AddonsConfig.yaml b/mmv1/products/apigee/AddonsConfig.yaml index c745403af2fe..8ed4dd1f81ae 100644 --- a/mmv1/products/apigee/AddonsConfig.yaml +++ b/mmv1/products/apigee/AddonsConfig.yaml @@ -27,6 +27,9 @@ update_url: 'organizations/{{org}}:setAddons' update_verb: 'POST' delete_url: 'organizations/{{org}}:setAddons' delete_verb: 'POST' +collection_url_key: 'organizations' +generate_list_resource: true + timeouts: insert_minutes: 20 update_minutes: 20 @@ -39,6 +42,7 @@ async: result: resource_inside_response: false custom_code: + decoder: 'templates/terraform/decoders/apigee_addons_config.go.tmpl' custom_import: 'templates/terraform/custom_import/apigee_addons.go.tmpl' test_check_destroy: 'templates/terraform/custom_check_destroy/apigee_addons_override.go.tmpl' custom_identity: diff --git a/mmv1/products/apigee/ApiProduct.yaml b/mmv1/products/apigee/ApiProduct.yaml index 2d122ee235d7..23e522e8387b 100644 --- a/mmv1/products/apigee/ApiProduct.yaml +++ b/mmv1/products/apigee/ApiProduct.yaml @@ -76,6 +76,9 @@ samples: test_env_vars: org_id: ORG_ID billing_account: BILLING_ACCT +collection_url_key: 'apiProduct' +generate_list_resource: true + parameters: - name: orgId type: String diff --git a/mmv1/products/apigee/EnvironmentKeyvaluemaps.yaml b/mmv1/products/apigee/EnvironmentKeyvaluemaps.yaml index 53307277d58f..6c69abb1e233 100644 --- a/mmv1/products/apigee/EnvironmentKeyvaluemaps.yaml +++ b/mmv1/products/apigee/EnvironmentKeyvaluemaps.yaml @@ -29,6 +29,9 @@ delete_url: '{{env_id}}/keyvaluemaps/{{name}}' import_format: - '{{env_id}}/keyvaluemaps/{{name}}' - '{{env_id}}/{{name}}' +generate_list_resource: true +list_response_is_array: true + timeouts: insert_minutes: 1 update_minutes: 20 diff --git a/mmv1/products/apigee/Organization.yaml b/mmv1/products/apigee/Organization.yaml index 423034159169..c34d9ea72430 100644 --- a/mmv1/products/apigee/Organization.yaml +++ b/mmv1/products/apigee/Organization.yaml @@ -46,6 +46,7 @@ sweeper: identifier_field: "organization" custom_code: encoder: 'templates/terraform/encoders/apigee_organization.go.tmpl' + decoder: 'templates/terraform/decoders/apigee_organization.go.tmpl' custom_import: 'templates/terraform/custom_import/apigee_organization.go.tmpl' samples: - name: 'apigee_organization_cloud_basic' diff --git a/mmv1/products/apigee/TargetServer.yaml b/mmv1/products/apigee/TargetServer.yaml index c07a2cbc2d3d..a7cb3d1e8cf8 100644 --- a/mmv1/products/apigee/TargetServer.yaml +++ b/mmv1/products/apigee/TargetServer.yaml @@ -28,6 +28,9 @@ update_url: '{{env_id}}/targetservers/{{name}}' import_format: - '{{env_id}}/targetservers/{{name}}' - '{{env_id}}/{{name}}' +generate_list_resource: true +list_response_is_array: true + timeouts: insert_minutes: 1 update_minutes: 1 diff --git a/mmv1/templates/terraform/decoders/apigee_addons_config.go.tmpl b/mmv1/templates/terraform/decoders/apigee_addons_config.go.tmpl new file mode 100644 index 000000000000..fc969dc261ba --- /dev/null +++ b/mmv1/templates/terraform/decoders/apigee_addons_config.go.tmpl @@ -0,0 +1,9 @@ +// The list response returns organization objects where the org name is in "organization". +// Extract the org name and set it on the resource data so the +// identity and id can be constructed correctly. +if orgName, ok := res["organization"].(string); ok && orgName != "" { + if err := d.Set("org", orgName); err != nil { + return nil, fmt.Errorf("error setting org from list response: %w", err) + } +} +return res, nil diff --git a/mmv1/templates/terraform/decoders/apigee_organization.go.tmpl b/mmv1/templates/terraform/decoders/apigee_organization.go.tmpl new file mode 100644 index 000000000000..2a5f682b5131 --- /dev/null +++ b/mmv1/templates/terraform/decoders/apigee_organization.go.tmpl @@ -0,0 +1,9 @@ +// The list response returns items where the org name is in "organization". +// For a direct read response the org name is already in "name". Normalise so +// the identity field ("name") is always populated. +if name, ok := res["name"].(string); !ok || name == "" { + if orgName, ok := res["organization"].(string); ok && orgName != "" { + res["name"] = orgName + } +} +return res, nil diff --git a/mmv1/templates/terraform/list_resource_method.go.tmpl b/mmv1/templates/terraform/list_resource_method.go.tmpl index 553c0f7b3dba..9524e8c66a82 100644 --- a/mmv1/templates/terraform/list_resource_method.go.tmpl +++ b/mmv1/templates/terraform/list_resource_method.go.tmpl @@ -51,6 +51,15 @@ func List{{ $.ResourceName }}s(config *transport_tpg.Config, return err } +{{- if $.ListResponseIsArray }} + return transport_tpg.ListArrayPages(transport_tpg.ListArrayPagesOptions{ + Config: config, + TempData: resourceData, + Resource: Resource{{ $.ResourceName -}}(), + ListURL: url, + BillingProject: billingProject, + UserAgent: userAgent, +{{- else }} return transport_tpg.ListPages(transport_tpg.ListPagesOptions{ Config: config, TempData: resourceData, @@ -62,6 +71,7 @@ func List{{ $.ResourceName }}s(config *transport_tpg.Config, {{- if $.ListFilter }} Filter: "{{ $.ListFilter }}", {{- end }} +{{- end }} Flattener: func(res map[string]interface{}, d *schema.ResourceData, config *transport_tpg.Config) error { headers := make(http.Header) var err error diff --git a/mmv1/third_party/terraform/transport/transport.go b/mmv1/third_party/terraform/transport/transport.go index 11defd51a335..c3260b25a6e5 100644 --- a/mmv1/third_party/terraform/transport/transport.go +++ b/mmv1/third_party/terraform/transport/transport.go @@ -387,3 +387,93 @@ func ListPages(opt ListPagesOptions) error { } return nil } + +type ListArrayPagesOptions struct { + Config *Config + TempData *schema.ResourceData + Resource *schema.Resource + ListURL string + BillingProject string + UserAgent string + Flattener func(item map[string]interface{}, d *schema.ResourceData, config *Config) error + Callback func(rd *schema.ResourceData) error +} + +// ListArrayPages performs a GET request against ListURL where the response body is a +// JSON array (not an object with a nested array key). Each element is passed to +// Flattener and then to Callback. Rate-limited responses (HTTP 429) are retried. +func ListArrayPages(opt ListArrayPagesOptions) error { + url, err := AddQueryParams(opt.ListURL, map[string]string{}) + if err != nil { + return err + } + + reqHeaders := make(http.Header) + reqHeaders.Set("User-Agent", opt.UserAgent) + reqHeaders.Set("Content-Type", "application/json") + if opt.Config.UserProjectOverride && opt.BillingProject != "" { + if opt.BillingProject == "NO_BILLING_PROJECT_OVERRIDE" { + reqHeaders.Set("X-Goog-User-Project", "") + } else { + reqHeaders.Set("X-Goog-User-Project", opt.BillingProject) + } + } + + var res *http.Response + err = Retry(RetryOptions{ + RetryFunc: func() error { + u, err := AddQueryParams(url, map[string]string{"alt": "json"}) + if err != nil { + return err + } + req, err := http.NewRequest("GET", u, nil) + if err != nil { + return err + } + req.Header = reqHeaders + res, err = opt.Config.Client.Do(req) + if err != nil { + return err + } + if err := googleapi.CheckResponse(res); err != nil { + googleapi.CloseBody(res) + return err + } + return nil + }, + Timeout: DefaultRequestTimeout, + ErrorRetryPredicates: []RetryErrorPredicateFunc{Is429RetryableQuotaError}, + }) + if err != nil { + return HandleListGoogleApiError(err, url) + } + if res == nil { + return fmt.Errorf("unable to parse server response for list at %s", url) + } + defer googleapi.CloseBody(res) + + if res.StatusCode == 204 { + return nil + } + + var items []interface{} + if err := json.NewDecoder(res.Body).Decode(&items); err != nil { + return fmt.Errorf("error decoding array response from %s: %w", url, err) + } + + seedState := opt.TempData.State() + for _, item := range items { + itemMap, ok := item.(map[string]interface{}) + if !ok { + return fmt.Errorf("expected item to be map[string]interface{}, got %T", item) + } + itemResourceData := opt.Resource.Data(seedState) + if err := opt.Flattener(itemMap, itemResourceData, opt.Config); err != nil { + return fmt.Errorf("error flattening instance: %s", err) + } + if err := opt.Callback(itemResourceData); err != nil { + return err + } + } + return nil +} From 6ced62c66122894acc062ae09d943b102a7d3457 Mon Sep 17 00:00:00 2001 From: "alvarez.mauriciotm@gmail.com" Date: Thu, 30 Jul 2026 14:33:22 -0700 Subject: [PATCH 2/2] list_resource.go.tmpl: guard types import on ListScopeProperties Resources with no scope params (e.g. AddonsConfig) do not reference types.String and produced an 'imported and not used' compile error. --- mmv1/templates/terraform/list_resource.go.tmpl | 2 ++ 1 file changed, 2 insertions(+) diff --git a/mmv1/templates/terraform/list_resource.go.tmpl b/mmv1/templates/terraform/list_resource.go.tmpl index 43b72967fb8a..a067f5142b3c 100644 --- a/mmv1/templates/terraform/list_resource.go.tmpl +++ b/mmv1/templates/terraform/list_resource.go.tmpl @@ -37,7 +37,9 @@ import ( "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/list" + {{- if $.ListScopeProperties }} "github.com/hashicorp/terraform-plugin-framework/types" + {{- end }} "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" "github.com/hashicorp/terraform-plugin-sdk/v2/terraform"