diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index d73401a..dc58c69 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -41,6 +41,18 @@ jobs: - name: Vet run: go vet ./... + # The generated schema/model code under internal/provider/codegen is + # committed, so it can silently drift from its inputs (the vendored spec and + # codegen/ruletypesgen). Regenerating here proves the committed output is + # what the inputs actually produce. + - name: Generated code is up to date + run: | + make generate + if ! git diff --exit-code; then + echo "::error::make generate produced a diff. Run it locally and commit the result." + exit 1 + fi + - name: Build run: | mkdir bin diff --git a/AGENTS.md b/AGENTS.md index 749e02b..2d2849e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,9 +1,8 @@ # Working in this repo (for AI agents) This is the Ably Terraform provider. It manages Ably resources (apps, keys, -namespaces, queues, integration rules) via the Ably Control API. The provider's -schema and model code is being moved onto code generation from the Control API -spec; `CODEGEN_STRATEGY.md` explains the why, the decisions, and the plan. +namespaces, queues, integration rules) via the Ably Control API. Its schema and +model code is generated from the Control API's OpenAPI spec. ## The test loop (run this on every change) @@ -28,31 +27,17 @@ CI also enforces `gofmt` and `go vet`, so keep `gofmt -l .` clean and ## Code generation -Schema and model code is generated. Regenerate with: - -```sh -make generate -``` - -Generated code lives under `internal/provider/codegen/` and **is committed; do -not hand-edit it**. Change the inputs and regenerate: - -- Simple resources (app, namespace, queue) are generated from the vendored - OpenAPI spec `codegen/control-api.yaml` (sourced from the `ably/docs` repo). -- Integration-rule families are generated from the in-repo `control` rule types - via `codegen/ruletypesgen`, with descriptions and metadata sourced from the - spec and an overrides table. - -Generation produces schema + model only. **CRUD wiring to the control client is -always hand-written.** - -Step-by-step runbooks are in `DEVELOPMENT.md`: - -- "Adding a new integration rule" -- "Porting a resource onto generated code" (reference example: - `internal/provider/resource_ably_rule_bodyguard.go`) - -and the pipeline details are in `codegen/README.md`. +Schema and model code for resources and data sources is generated with `make +generate`, and the output is committed under `internal/provider/codegen/`. **Do +not hand-edit it**: change the inputs in `codegen/` and regenerate. Generation +produces schema + model only, so **CRUD wiring to the control client is always +hand-written**. The pipeline is described in `codegen/README.md`, and +`DEVELOPMENT.md` has the runbooks for adding a rule or a data source and for +porting a resource onto generated code. + +`internal/provider/spec_coverage_test.go` fails when the Control API spec carries +rule types or operations the provider hasn't accounted for, so new API surface has +to be a decision someone writes down rather than something we miss. ## The account exporter diff --git a/CODEGEN_STRATEGY.md b/CODEGEN_STRATEGY.md deleted file mode 100644 index 2dae921..0000000 --- a/CODEGEN_STRATEGY.md +++ /dev/null @@ -1,261 +0,0 @@ -# Generating the Ably Terraform Provider from the Control API spec: build vs buy - -## 1. Problem and the constraint that drove hand-writing - -We document the Control API with an OpenAPI 3.0.1 swagger spec (`swagger/v1/swagger.yaml` in the `website` repo, ~5,852 lines). We do not want to keep hand-maintaining three layers of code that all ultimately derive from that spec: - -1. the in-repo `control` package (Go client: methods + types that talk to the API), -2. the Terraform models (tfsdk structs that mirror the control types), -3. the Terraform resources/data-sources/import wiring. - -The constraint that made us hand-write the client in the first place still holds: when the team tried generators, the output was clunky. The research confirms exactly where "clunky" comes from, and it is not vague taste. The spec models 23 integration-rule variants as three parallel `oneOf` + `discriminator` unions (`rule_post`, `rule_patch`, `rule_response`) with discriminator values that are not valid identifiers (`http/ifttt`, `aws/lambda/before-publish`, `ingress-postgres-outbox`). The response union adds two synthetic variants (`webhook`, `unsupported`) with no request counterpart. Timestamps are `type:number` on rule responses but `type:integer` on app/key/namespace responses. Nullable fields are pervasive and many booleans carry the contradictory `default:false` + `nullable:true` pair. There is a multipart `pkcs12` upload, top-level array responses, and endpoints that return no body. On top of that the spec is not faithful to the shipped product: the client deliberately omits `/me/accounts` and `/help`, drops two fields the spec marks required on `MeToken`, and carries domain knowledge in comments that the spec does not have (Pulsar `authenticationMode: "token"` not `"jwt"`, write-once keys, delete cascades). - -So the honest framing is: the spec is good enough to scaffold the simple resources and useless-to-dangerous for the rules subsystem. Any decision has to treat those two worlds separately. - -## 2. Priority order: generator first, agent-readiness as the fallback - -The decisions below are not a free-for-all between "generate" and "hand-write". There is a strict priority, applied at every layer and every stage: - -1. **Generate it if we possibly can.** A generator is the preferred outcome everywhere, always. Even where this document concludes hand-writing wins today, that conclusion is provisional: when the blocking tool matures (e.g. `oneOf` support lands in `tfplugingen-openapi`, or we accept the oapi-codegen leaf-DTO hybrid for the client), we should move that surface back to generation. Generation is the goal; hand-writing is a concession we make only where generation is genuinely not feasible right now. -2. **Where generation genuinely isn't feasible, make the hand-written surface trivial for AI agents to extend.** This is the fallback, not a parallel option. The aim is to get as close to the leverage of a generator as possible (low-effort, low-error addition of new functionality) without one, so AI agents can do the development. That makes the hand-written rule and resource code a first-class agent-extensibility target. - -Concretely, "agent-ready" means: - -- **Clear, uniform examples.** Every rule/resource follows one identical pattern with low variance, so an agent copies the right thing. The odd-one-out is the enemy: one rule that does it differently teaches the agent the wrong lesson. -- **A fast, hermetic feedback loop.** One command that runs compile + lint + unit tests with no API credentials and returns unambiguous pass/fail. Agents are only as good as the signal they can get unattended, and today the acceptance tests need a real Control API (`internal/provider/e2e_test.go`), which an agent cannot run in-loop. This is the highest-value gap to close. -- **Loud tests on the footguns, not the happy path.** The silent-correctness traps this document already flags (PATCH clobbering server state, the sensitive-attribute "inconsistent value" diffs, the `bool` vs `*bool` namespace split) are exactly what agents get wrong, because they pattern-match the happy path and will not reason about partial-update semantics unless a test forces them to. "Let agents do the development" only works if those traps fail by default in tests. Agent-priority has to come bundled with footgun-priority, or we just ship the silent bugs faster. -- **Strong, task-shaped docs.** Not prose about the architecture, but a runbook: "to add a new rule, touch these N files in this order, here is the reference change." Better still, the scaffolding generator stamps the skeleton so the agent only fills the gaps. -- **A spec-drift signal.** A CI check that diffs the upstream swagger against what the provider implements, turning "not feature complete" from a vibe into a worklist an agent can pick up. Without it, an agent never knows there is work to do. - -Note the convergence: every one of these (uniformity, scaffolding, hermetic tests, runbook) is also exactly the substrate a custom generator needs. The agent-readiness work is not a detour away from the generator, it is the groundwork for it. The two strategies reinforce each other rather than competing, which is why the fallback is safe to invest in even while generation stays the priority. - -## 3. Build-vs-buy decision, per layer - -### Layer 1: the `control` Go client — hand-written stays - -Decision: keep `control/` hand-written. Do not adopt a generator wholesale. - -The four candidates surveyed: - -- **go-swagger** is disqualified immediately. It is OpenAPI 2.0 only and our spec is 3.0.1. `oneOf` does not even exist in its model. Down-converting to 2.0 would throw away the discriminator unions entirely. Do not pursue. -- **openapi-generator (Go target)** supports 3.x but produces the least idiomatic Go, mangles initialisms (`Id`/`Url`/`Apns` instead of `ID`/`URL`/`APNS`), and has the most fragile `oneOf`/discriminator handling of the lot, with known unmarshal panics about data matching more than one schema. That is precisely the shape this spec leans on hardest. It also has no clean seam for our `retryablehttp` transport. Poor fit. -- **oapi-codegen** is the strongest single candidate. It drops our existing `go-retryablehttp` client in cleanly via its `HttpRequestDoer` `Do(*http.Request)` interface, so the bespoke 5xx-only `retryPolicy` in `control/client.go` survives verbatim. The Post/Patch split is free because the spec already separates `http_rule_post` from `http_rule_patch`. But it mangles initialisms unless we maintain `x-go-name` overrides or a name-normalizer, its multipart support is the tool's rough edge (open issues into 2026), and its discriminated-union output is a `json.RawMessage` wrapper with `ValueByDiscriminator()` that is less type-safe and less ergonomic than the typed rule structs we have today. -- **ogen** models the hard features best: real Go sum types with native discriminator dispatch, the best initialism handling, solid multipart. The cost is rigidity. Its aggressive validation will likely force spec cleanup before it generates at all, and it bakes in its own error/tracing/client shape, so wedging in our retry policy means adopting ogen's model rather than keeping ours. - -No single tool wins every axis, and the things generators do worst are exactly our load-bearing decisions: collapsing the 24-member `rule_response` `oneOf` into one `RuleResponse` with `Target interface{}` (`control/types.go:479-491`), `CreateRule`/`UpdateRule` taking `any` (`control/rules.go:22,38`), the per-resource `float64` vs `int64` timestamp split, the `NamespacePost` `bool` vs `NamespacePatch` `*bool` distinction tied to `default:false`+nullable, the hand-framed multipart pkcs12 upload, and the domain knowledge in comments. The first three and the comments are not realistically reproducible from the spec at all. The correctness-affecting ones (Post/Patch pointer+omitempty, the bool split, the stats nil-means-defaults builder, multipart) are reachable only with heavy config, and getting them wrong produces silent bugs (PATCH clobbering server state), not just ugly names. - -The generator upside here is small because the one thing generators do for free (the Post/Patch split) we already get from the spec structure, and the boilerplate they would save is dwarfed by the hand-patching they would require forever after, plus owning a codegen pipeline in CI. - -If we ever want to cut maintenance on this layer, the realistic move is a **hybrid, not a replacement**: use oapi-codegen to generate only the leaf variant DTOs (the 23 `rule_post`/`rule_patch`/`rule_response` concrete structs) with `x-go-name` annotations for initialisms, and keep the hand-written `client.go` transport, retry policy, and typed union dispatch. That captures most of the boilerplate without surrendering the parts generators do badly. But that is a later optimisation, not the recommendation now. - -### Layer 2: the Terraform models (tfsdk schema + model structs) — off-the-shelf with post-processing, for the four simple resources only - -Decision: use the HashiCorp chain (`tfplugingen-openapi` -> `tfplugingen-framework generate`) to produce schema + model Go for `ably_app`, `ably_key`, `ably_namespace`, `ably_queue` only. Generate nothing for rules from the OpenAPI front-end. Hand-write or script the rule models. - -Why this split: `tfplugingen-openapi` flatly cannot handle `oneOf` + discriminator. HashiCorp issue #94 has been open since Nov 2023 with no maintainer commitment, and issue #82 confirms resources containing `oneOf`/`anyOf` children get silently skipped with warnings. The rules are 17 of 19 resources. Pointing the OpenAPI generator at them gets us nothing. But the simple resources are plain object schemas, and `ably_app` alone is ~25 attributes with sensitive flags and defaults, so generating its schema/model from the spec is genuine value that then tracks the spec automatically. - -Carry the caveat honestly: every tool in this chain is **tech preview**. `tfplugingen-openapi` has had no release since v0.3.0 (Jan 2024), `tfplugingen-framework` is at v0.4.1 (Sep 2024), `terraform-plugin-codegen-spec` at v0.2.0 (Sep 2024). I am not betting the rule pipeline on a near-future `oneOf` fix. - -For the rule models, the escape hatch is `terraform-plugin-codegen-spec`'s Go bindings: a small Go program walks the in-repo `control` rule types (`control/rule_types_*.go`) and emits one Provider Code Spec entry per rule variant, which then feeds the same `tfplugingen-framework generate` stage. This is more reliable than waiting on #94 and mirrors the existing hand-flattened `GetRuleSchema` approach. - -I am explicitly rejecting **Speakeasy** as the default here. It is the only tool that claims `oneOf` + full CRUD, but it generates and owns its own SDK runtime, which directly fights the in-repo `control` client we just migrated to (commit `47f5ba8`), and it wants `x-speakeasy-entity` annotations on a spec owned by another repo. It is worth a time-boxed spike, not an adopt. - -### Layer 3: resources / data-sources / CRUD / import — custom generator for the rule fan-out, hand-written stays for the rest - -Decision: no off-the-shelf tool produces this layer for us. Build a small custom generator for the per-rule resource files; leave everything else hand-written. - -The HashiCorp chain emits schema + model only. `generate` does not wire CRUD; `scaffold` emits unwired stubs. So all wiring to the `control` client is manual regardless of tool. The provider-layer audit makes the shape clear: the 15 thin per-rule `.go` files (`resource_ably_rule_http.go` etc., ~70-105 lines each, ~1,200 lines total) are near-pure boilerplate. Each is a struct, interface assertions, a `Schema()` calling `GetRuleSchema` with a literal target map, `Metadata`, `Provider()`/`Name()`, and five one-line CRUD/Import delegations to the already-written generic `CreateRule[T]`/`ReadRule[T]`/etc. over `AblyRuleDecoder[T]`. That is the clean generation win, and a small templated generator keyed off the control rule types produces it. - -The value-add code that a generator must **not** try to own lives in three places per rule: the tfsdk target model struct in `models.go`, and the `GetPlanRule` / `GetRuleResponse` switch cases. Those carry per-field type/helper choices, the `RuleType` string literals (`http/cloudflare-worker`, `ingress/mongodb`), and roughly seven non-generatable special cases: AWS auth mode branching with `secret_access_key` pulled from prior plan, `webhookEnveloped` forcing `enveloped=false` in batch mode, the kinesis/sqs `request_mode != single` guard, Pulsar `tls_trust_certs` write-only preservation, and amqp/external `url`/`exchange`/`message_ttl` preservation to dodge the "inconsistent values for sensitive attribute" error. A generator must take a per-field mapping table as input and leave explicit escape hatches for these, or it will reintroduce perpetual diffs. - -For the four non-rule resources, the load-bearing logic is hand-written and field-specific: read-back-via-List because there is no get-by-id, write-only secret preservation (`fcm_key`, `apns_*`), the capability map<->set conversion with `SortSetsInMap`, namespace conditional batching/conflation with a Create-vs-Update difference, queue immutability, RFC3339-vs-Int64 timestamp handling, the provider `Configure` bootstrap, and `TypeName` mismatches (`ably_api_key`, `ably_ingress_rule_mongodb`). Generation here would reproduce skeletons and leave all the load-bearing code by hand. Not worth a generator. Keep it hand-written. - -## 4. Recommended end-to-end pipeline - -Two source-of-truth tracks feeding one code-generation back end, with a curated intermediate model to absorb the spec-fidelity gaps. - -**Spec hygiene (shared, up front).** The swagger lives in the website repo, so we do not edit it in place. We maintain an **overlay/patch file in this repo** that the pipeline applies before any generation: add `x-go-name` for initialisms, drop the `/me/accounts` and `/help` endpoints we do not ship, and set `ignores` in the `tfplugingen-openapi` config to suppress the rule `oneOf` so the simple resources generate without aborting. The overlay is our seam for keeping the website spec untouched while the generators see a spec they can swallow. - -**Track A: simple resources (app, key, namespace, queue).** -1. `swagger.yaml` + overlay + a hand-written `generator_config.yml` (mapping each resource to its create/read/update/delete path+method) -> `tfplugingen-openapi generate` -> `provider_code_spec.json`. -2. `provider_code_spec.json` -> `tfplugingen-framework generate resources|data-sources` -> Go schema + model files. -3. Hand-written (stays): CRUD wiring to `control`, timestamp transforms, write-only field preservation, read-back-via-List, plan modifiers and defaults, `Configure` bootstrap. - -**Track B: rules (15 resources).** -1. A small Go program using `terraform-plugin-codegen-spec` Go bindings walks the in-repo `control/rule_types_*.go` types and emits one Provider Code Spec entry per rule variant. This is where we solve the `oneOf` problem: we never ask any OpenAPI tool to read the discriminated union. The in-repo control types are already the curated, per-family-correct model (ingress/before-publish/moderation families correctly drop `requestMode`/`source`), so they are a better source of truth for the TF schema than the spec is. -2. That spec JSON -> `tfplugingen-framework generate` -> rule schema + model Go. -3. A **custom templated generator** (also keyed off the control rule types plus a per-field mapping table) emits the 15 thin per-rule resource files that delegate to the existing generic `CreateRule[T]` plumbing, with explicit hook points for the ~7 special cases. - -**Handling the spec-fidelity gaps:** the overlay deletes the unshipped endpoints and the response-only `webhook`/`unsupported` variants are simply never modelled (the control client's `Target interface{}` already absorbs them; our TF rule generation is driven from control types, not the spec, so they never appear). The `MeToken` required-field divergence and the timestamp-type split stay encoded in the hand-written control types, which are the source of truth for Track B. In short: the spec drives the four simple resources, the curated control package drives the rules. - -## 5. Phased implementation plan - -**Phase 0 — Spike to de-risk (1-2 days). Do this first.** Two narrow spikes, both cheap and both gating: -- Run `tfplugingen-openapi` over the overlaid spec for `ably_app` only, with rules ignored, and confirm it produces a spec JSON, then `tfplugingen-framework generate` produces compilable schema/model that matches the existing `ably_app` contract. This validates the whole Track A chain on the hardest simple resource before we commit. -- Write a throwaway Go program that emits a Provider Code Spec entry for one rule variant (say `http`) from its control type, and run `tfplugingen-framework generate` on it. This validates that the codegen-spec bindings are a viable front end for rules. - -If either spike fails badly, we fall back to fully hand-written for that track and lose little. - -**Phase 0c — Agent-extensibility spike (1-2 days). The most important spike.** Take the codebase exactly as it is today, with no special preparation, and have an AI agent add one genuinely-missing resource end-to-end: schema, model, CRUD wiring, import, docs, and acceptance test. Watch precisely where it stumbles, missing examples, weak docs, or the inability to verify itself without real API credentials. That is direct evidence for what the agent-readiness backlog actually is, rather than us guessing, and it tells us how close the hand-written fallback can get to generator-level leverage. - -Concrete target: the `control` package already fully supports before-publish rules (`BeforePublishWebhook*`, `BeforePublishAWSLambda*` in `control/rule_types_before_publish.go`) and the five moderation rules (`HiveTextModelOnly`, `HiveDashboard`, `BodyguardTextModeration`, `TisaneTextModeration`, `AzureTextModeration` in `control/rule_types_moderation.go`), but NONE of them are exposed as Terraform resources, and the provider currently ships ZERO data sources (`DataSources()` returns an empty slice in `provider.go:181`). So the agent's task is pure Terraform-layer work over an already-written client type, following the established per-rule pattern. Pick one moderation rule as the target: these drop `requestMode`/`source`, so they are a representative variant rather than a vanilla `http` clone, which makes the spike honest about whether the pattern holds for non-identical cases. Whatever the agent struggles with becomes the agent-readiness backlog (and, not coincidentally, the requirements for the Phase 3 custom rule generator). - -If Phase 0/0a/0b show generation is blocked for a track, the agent-readiness work for that track becomes the funded path per the priority order in Section 2, while we keep generation on the table for when the blocking tool matures. - -**Phase 0c results (run 2026-06-08).** We ran this spike. An AI agent, given no coaching beyond "add the Bodyguard text moderation rule following the repo's conventions", produced a compiling, unit-tested, documented, registered `ably_rule_bodyguard` resource. `go build`, `go vet`, `gofmt`, the provider unit tests, `golangci-lint`, and `tfplugindocs generate` all ran green unattended. The acceptance test could not run: it needs a real Control API token, and that is the finding that matters most. - -Verified against the code, not just the agent's self-report: - -- **The generic rule plumbing is silently webhook-only, and this is the keystone problem.** `GetRuleSchema` (`internal/provider/rules.go:704`) hardcodes `source` as a `Required` nested attribute and adds `request_mode`, and the generic `AblyRule` decoder (`models.go:203-204`) bakes in `RequestMode`/`Source`. `BodyguardTextModerationRulePost` has neither; it has `BeforePublishConfig`/`InvocationMode`/`ChatRoomFilter`/`Target`. So the dominant, most-copyable pattern (13 of the rule resources delegate to `CreateRule[T]`) is a trap for the entire moderation/before-publish family: copying it yields a resource with a bogus required `source` block, a `request_mode` the API rejects, and no `before_publish_config`, that still compiles and looks right. The agent avoided it only by reading the control types closely; a cheaper agent would not. `internal/provider/ingress_rules.go` is the existing precedent for a sourceless family, but nothing signposts it. Fixing this (a second generic family, or at minimum loud "webhook-only" guards) is the single highest-value change, and it is the same refactor a Phase 3 generator would need. -- **`RuleResponse` drops the moderation fields on read.** `control/types.go` `RuleResponse` carries no `beforePublishConfig`/`invocationMode`/`chatRoomFilter`, and `apiKey` is write-only. A naive read maps them to null and Terraform throws "inconsistent result after apply" plus sensitive-attribute diffs. The need is documented only in amqp/pulsar comments in `rules.go`. The agent wrote a preserve-from-plan block and a loud unit test for it, exactly the footgun-test pattern Section 2 calls for. -- **No hermetic end-to-end loop is the real blocker.** Everything except the live round-trip verified unattended. The one test that proves the resource actually works (CRUD, import, whether the API returns the moderation fields) needs a credential the agent cannot have, so its read logic is inferred, not proven. This is the gap that stops us trusting agent-authored resources without a human in the loop, and it motivates the local end-to-end test environment in Section 7. -- Minor: resource naming and the `bodyguard/text-moderation` discriminator were guessed from comments (no importable constant, no written naming rule); `provider.go` registration has no checklist, so an agent can ship an unregistered resource. - -Net: the thesis holds, an agent can extend this codebase, but unsupervised correctness depends on two things the spike shows are missing: the structural fix to the rule plumbing, and a credential-free round-trip test. - -**Phase 0a/0b results (run 2026-06-09).** Ran both generator de-risk spikes. Both passed, so per the priority order in Section 2 we generate rather than hand-build. - -- Track A (0a): `tfplugingen-openapi` then `tfplugingen-framework` on `ably_app`, end to end. Produced a 407-line schema/model that compiles against the repo's framework deps. Optionality inference was good (`name` came out Required, the `*_configured` flags Computed, the push secrets Optional+Computed) and it lifted the `apns_auth_type` enum (`OneOf("certificate","token")`) straight from the swagger for free. Two manual touches were needed: alias the `account_id` path parameter so it does not collide with the `accountId` response field (framework-gen errors on the duplicate otherwise), and decide what to do with `_links` (the tool models it as a typed nested object; the hand-written client treats it as raw JSON). One useful realisation: the Go-initialism problem is a Layer 1 (client) concern only. The Terraform schema layer is snake_case anyway, so it does not arise here. Net: viable for the four simple resources with a small per-resource config and light post-processing. -- Track B back half (0b): hand-authored a Provider Code Spec for the source-less Bodyguard moderation shape (nested `before_publish_config` and `target`, sensitive `api_key`, no `source`/`request_mode`) and ran `tfplugingen-framework generate`. It produced compilable schema + model with the correct nested types and none of the webhook fields. So the framework stage handles the moderation family cleanly; the only remaining Phase 2 work is the mechanical emitter that walks the in-repo control rule types into a Provider Code Spec. - -Carry the caveat: `tfplugingen-openapi` is still tech preview and last shipped Jan 2024, but it works on our spec today. Both tools emit schema + model only; all CRUD wiring to the control client stays hand-written, as the plan assumed. - -Implication: the moderation/before-publish rule family should be generated, not hand-built. Spiking before committing to a hand-build was the right call. - -**Phase 1 — Track A simple resources (3-5 days).** Build the overlay, the `generator_config.yml`, and wire `tfplugingen-openapi` -> `tfplugingen-framework generate` for app/key/namespace/queue into a `make generate` target. Diff the generated schema/model against the current hand-written ones and reconcile. CRUD wiring stays hand-written but now sits on top of generated schema/model. Effort is dominated by reconciling generated attribute metadata against our existing defaults/sensitive flags. - -**Phase 1 progress (2026-06-09).** Stood up the pipeline: vendored the spec at `codegen/control-api.yaml`, wrote `codegen/generator_config.yml`, added a `make generate` target, and generated compiling schema/model for app, namespace and queue under `internal/provider/codegen/` (committed but not yet wired into the live resources). What the generated output taught us about the reconciliation cost: - -- **The pipeline works and the bool defaults are a real win.** Namespace's generated schema lifted `booldefault.StaticBool(false)` for all eight always-on flags straight from the spec's `default: false`, and inferred `id` Required with the `*_configured`/timestamp fields Computed. That metadata is genuinely useful and tracks the spec. -- **The spec is description-poor, so generated schemas lose our docs.** Our hand-written schemas carry rich attribute descriptions; the swagger has almost none, so the generated schemas have none. Adopting them as-is regresses the registry docs. Closing that needs a description overlay on the spec, which is more work than the generation saves for a 14-to-18-attribute resource. -- **Parent path params collide with response fields.** `account_id`/`app_id` appear both as a path parameter and as a response body field, and framework-gen errors on the duplicate. Worked around with an alias per resource; the redundant attribute then has to be dropped during integration. -- **Some resources diverge from the spec shape on purpose.** `queue` flattens the API's nested `amqp`/`stomp` objects into flat `amqp_uri`/`stomp_uri` attributes and drops `messages`/`stats` entirely. The generator faithfully reproduces the nested spec shape, so adopting it would be a breaking change to a GA resource. `queue` is therefore not a generation target without an overlay that restructures it. - -The steer this gives: retrofitting the existing hand-tuned GA simple resources onto generated schema is a modest, lossy win (descriptions lost, metadata patched, one resource breaking). The bigger prize is generating NEW surface where there is no hand-written schema to preserve and no breaking-change risk, i.e. the moderation/before-publish rule family (Phase 2). Recommend reordering: do Phase 2 (rule families driven from the control types) next, and treat the simple-resource retrofit as lower priority, gated on whether a description overlay is worth maintaining. - -**Track A porting blockers (recorded 2026-07-08).** The generated app/namespace/queue schemas compile but are NOT contract-complete against the hand-written resources; nobody should port a resource onto them until the gap below is closed for that resource. This is deliberate porting work, not generator work: each item is metadata the spec cannot express and belongs in a per-resource reconciliation when (if) the port happens. Review flagged all of these on PR #237. - -- **namespace**: no `identified` attribute and a bare `authenticated` bool with a hard `false` default. The hand-written schema carries the whole INF-7589 migration (canonical `identified`, deprecated `authenticated` alias, alias plan modifiers, `ConflictsWith`, deprecation message) that a port must preserve. `batching_interval` also gains a spurious default of 20 (hand-written: null + `AtLeast(0)` validator), and `id`/`app_id` lose `RequiresReplace`. -- **queue**: every `RequiresReplace` is lost (`app_id`, `name`, `ttl`, `max_length`, `region`) even though the Control API has no queue-update endpoint, so a ported resource would plan un-executable in-place updates. Plus the pre-existing shape divergence above (flattened `amqp`/`stomp`). -- **app**: generated `created`/`modified` are Int64 versus the hand-written RFC3339 strings, which breaks decoding of every existing state file; `UseStateForUnknown` on `id`/`account_id`/`created` and the `status`/`tls_only`/`apns_use_sandbox_endpoint` defaults are all dropped. - -**Pilot port + mass-port plan (2026-06-09).** Ported `ably_rule_bodyguard` onto its generated schema as the reference example (the runbook is in DEVELOPMENT.md). The pattern: `Schema()` adopts the generated `RuleBodyguardResourceSchema`, strips the generated `CustomType` from the nested blocks so the existing plain-struct model reflects, and patches in the validators/defaults/plan modifiers; the model and CRUD stay hand-written and unchanged. Green on the hermetic suite, and bodyguard already passes staging. - -The pilot also closed the Track B description gap that the earlier note worried about. The rule emitter now sources field descriptions from the vendored docs spec (it reflects the Go structs for shape, but the structs carry no docs), so all seven rule families generate documented schemas, and the bodyguard registry doc came back richer than the hand-written original. So descriptions are solved for both tracks (Track A from the OpenAPI spec, Track B via the emitter), and the "is a description overlay worth it" question is moot. - -What the pilot tells us about mass-porting: - -- The reliable win per resource is the generated schema: attribute set, types, nesting, sensitivity and descriptions all track the spec/control types automatically. That maintenance burden goes away. -- The residual hand-work per resource is (a) the metadata the generator can't express (enum validators, defaults, plan modifiers), patched in `Schema()`; (b) the CRUD wiring to the control client; (c) stripping the generated `CustomType` from nested blocks. -- (a) is identical in shape for every rule and is the obvious next automation: a per-field overrides table in `ruletypesgen` that emits validators/defaults/plan modifiers into the spec, shrinking the per-resource patch to near zero. - -Recommended mass-port order: - -1. Add the `ruletypesgen` overrides table (validators/defaults/plan modifiers) so rule ports become near-mechanical. -2. Port the remaining moderation/before-publish families (tisane, azure, hive x2, before-publish webhook/lambda) onto their generated schemas plus CRUD shims. These resources don't exist yet, so this is net-new coverage, not refactoring. -3. Build Phase 3 (the per-rule resource-file generator) only if the shims prove repetitive enough to template. The pilot suggests they will be. -4. Tackle the Track A simple-resource retrofit last and cautiously: app and namespace are clean-ish (modulo the `account_id`/`app_id` alias cleanup and the namespace `authenticated` bug, INF-7589); `queue` stays hand-written because its flattened schema can't be generated. -5. Phase 4 machinery: a no-diff CI check (`make generate` produces no diff) and a spec-drift check (docs spec versus implemented resources) so new API surface becomes a visible worklist. - -**Phase 2 — Track B rule schema/model generation (4-6 days).** Promote the Phase 0 rule spike into a real generator: walk all `control/rule_types_*.go`, emit spec entries for all 15 variants, generate schema/model. Carry the per-field type/helper mapping table as explicit generator input. - -**Phase 3 — Track B custom per-rule resource generator (3-5 days).** Template the 15 thin resource files delegating to the existing generic CRUD, with declared escape hatches for the ~7 special cases. This is the largest line-count win and the lowest-risk generation because the target files are already uniform. - -**Phase 4 — Pipeline, CI, and agent-readiness (2-3 days).** Single `make generate`, mark generated files clearly, add a CI check that regeneration produces no diff, document the overlay, and decide and document the regeneration cadence. Land the agent-readiness deliverables alongside, since they fall out of the same work: a hermetic `make test` (compile + lint + unit, no credentials) that an agent can run in-loop; a spec-drift CI check that diffs the upstream swagger against implemented resources and fails with the list of what is missing; and a short "add a new rule / resource" runbook pointing at the canonical example and the files to touch. If the agent spike surfaced specific footguns, add the loud tests for them here. - -**Layer 1 (the control client) gets no phase.** It stays hand-written. Revisit the oapi-codegen leaf-DTO hybrid only if rule-variant churn becomes a real maintenance cost. - -Rough total: ~3 weeks of focused work, front-loaded by a 1-2 day spike that can kill either track early. - -## 6. Key risks and open questions - -- **Everything in Track A is tech preview.** `tfplugingen-openapi` has not shipped since Jan 2024. If it stays stalled or the `tfplugingen-framework` spec format moves under us, we own a pipeline built on unmaintained tools. Mitigation: the generated output is plain framework Go we can fork and keep if the tools die. We are not locked in, but we would lose the regeneration benefit. -- **The overlay is cross-repo coupling.** The swagger lives in the website repo. If it changes shape (new rule family, renamed schema) our overlay and `generator_config.yml` can break silently. The CI no-diff check catches drift on our side but not upstream intent. Open question: do we want a contract test that fails when the upstream spec changes in a way the overlay does not cover? -- **The rule special cases are where regressions hide.** The seven hand-written carve-outs (AWS auth, batch `enveloped`, kinesis/sqs guard, write-only preservation) are exactly the bugs that produce perpetual diffs or sensitive-attribute errors. If the generator ever overwrites a hook point, behaviour regresses quietly. Mitigation: generated rule files must call out to hand-written hooks, never inline the special logic. -- **`tfplugingen-openapi`'s `allOf` behaviour is undocumented.** `DESIGN.md` does not cover it. If the simple-resource schemas use composition we may hit unspecified behaviour in Phase 1. The spike should check for `allOf` usage in the four simple schemas. -- **Confirm `ogen`/`oapi-codegen` claims by generating, not from docs.** The research flagged `ogen`'s exact `WithClient` signature and APNS-initialism output as medium-confidence. This only matters if we ever pursue the Layer 1 hybrid, but do not commit to oapi-codegen leaf-DTO generation without first generating and eyeballing the union and initialism output. -- **The generator-first commitment needs a trigger, or it rots into permanent hand-writing.** The priority order in Section 2 says we move surfaces back to generation as the blocking tools mature, but "mature" is not self-announcing. Open question: do we want a tracked check on `tfplugingen-openapi` issue #94 (`oneOf` support) and a periodic re-spike, so the agent-readiness fallback does not quietly become the permanent answer by default? The risk is that agents make hand-extension feel cheap enough that we stop reaching for the generator even once it becomes viable. -- **Open question: is the codegen-spec front end for rules worth it over staying hand-flattened?** The per-rule files are the win; the schema/model generation in Phase 2 is more marginal because `GetRuleSchema` already abstracts it well. If Phase 0's rule spike is awkward, we can ship Phase 3 (per-rule file generation) on top of hand-written schema/model and skip Phase 2 entirely. - ---- - -## 7. Local end-to-end test environment - -Phase 0c showed the real blocker to unsupervised agent development is not docs or examples, it is that nothing lets an agent prove a resource actually works without a production-grade credential. An agent can write a resource that compiles, lints, and unit-tests green, and still be confidently wrong about what the API returns on read. We need a credential-free way to close that loop, and a heavier way to prove the provider and the real service actually agree. - -### What "the Control API locally" actually involves - -It is not one service. The local Control API is two systems wired together: - -- **ably/website** serves the Control API HTTP surface (the `/v1` routes), does the JWT bearer-token auth, and keeps a Postgres mirror of accounts/apps/keys. Locally, with no `CONTROL_API_HOST` set, it mounts the Control API under `http://localhost:/api/v1`. It does not enforce HTTPS for localhost. -- **ably/realtime** (the "farm") is what actually provisions resources. The website's provisioner calls an Admin API via ActiveResource, and with `REALTIME_ENV=local` (already the default in `apps/website/.env`) that Admin API is the farm's, at `http://localhost:8090` with basic auth `admin`/`admin`. The farm is backed by Cassandra. - -So every resource-creating Control API call (apps, keys, namespaces, queues, rules, which is the entire provider) flows website -> Admin API -> farm. A website on its own cannot provision; the farm is a hard dependency. There is no standalone or mock Admin API in either repo, and nothing today wires the farm's datastore to a Control API stand-in. This is the heavy part, and it is undocumented as an end-to-end path. - -### The recommendation: three tiers, not one - -Making the full website+realtime stack the loop an agent runs on every change is the wrong target. It is heavy and, as below, partly human-gated. Split the need into three tiers: - -**Tier 1, the hermetic fake (the agent's inner loop, build this first).** The `control/` package already has exactly the right pattern: `control/testutil_test.go` stands up an `httptest` server, points a real `control.Client` at it, and runs fully offline with no token. The provider's acceptance tests don't do this yet, but they don't need a source change to: `provider.go` already takes `ABLY_URL`, the client does a plain `BaseURL + path` with no host allow-listing, and the account ID is discovered at runtime via `/me`. So we add a small stateful in-process fake Control API in `internal/provider/` (implements `/me` plus CRUD for the resources), set `ABLY_URL=srv.URL` and a dummy token, and the existing `resource.Test` flows run unchanged. - -- Catches: schema validation, plan/diff stability, full CRUD wiring, import ID parsing, attribute mapping, the negative `ExpectError` cases, and crucially the footguns, including the moderation read-preservation bug from Phase 0c (the fake returns what the real API returns, so a missing preserve-from-plan shows up as an inconsistent-result failure). All credential-free, offline, runnable on forks and by an agent unattended. -- Misses: whether the real API agrees. A fake that mis-models the API passes happily, so it must be kept honest (see below). -- This is the single change that unblocks unsupervised agent development, and it needs no live stack. - -**Tier 2, staging-backed acceptance (the real-behaviour gate, already exists).** CI already runs the full acceptance suite with `TF_ACC=1` against `https://staging-control.ably-dev.net/v1` using a repo-secret token. Every PR provisions and destroys real resources in staging. Keep this. It is the proof that the provider and a real Control API agree, and it is what keeps the Tier 1 fake honest. - -**Tier 3, the full local stack (heavyweight, for deep debugging and offline real-behaviour).** Stand up website+realtime locally when you genuinely need a real Control API offline. The runbook is below. The key realism: an agent can use this stack once it is warm by pointing `ABLY_URL` at it, but it cannot reliably cold-start it unattended (see blockers). So the model is: a human or a session-level setup brings it up, and the agent then drives it. - -### Tier 3 runbook (and its blockers) - -Order matters: the farm must be up before the website seeds, because seeding provisions through the Admin API. - -1. **Resolve the Redis collision first.** Both repos run Redis via Docker on host port 6379. In `apps/website/.env` set `REDIS_PORT=6380` and (re)run the website's `docker-compose up -d`. The website README documents exactly this for running against a local realtime service. -2. **Start the realtime farm** (from `ably/realtime`): `mise run shared-up` (Cassandra, Redis, RabbitMQ), `mise run cassandra-setup` (one-time), then `mise run farm -- --daemon --stop-existing --daemon-timeout=60`. Verify with `mise run farm-status` (expects `{"running":true,"healthy":true}`). Admin API lands on `localhost:8090`. -3. **Start the website** (from `ably/website/apps/website`): ensure `.env` has `REALTIME_ENV=local` (default) and add `JWT_SECRET` (a working value is in `.env.sample`; it is currently missing from the live `.env` and token auth fails without it). `docker-compose up -d`, then `bin/dev` for the first (seeding) boot and `bin/dev --quick` thereafter. Note the Rails port it prints. -4. **Mint a local Control API token**: `bin/rake "control_api:generate_tokens[1,1,/tmp/ably_tokens.json]"` writes `{account_id, tokens}`. The token is a self-contained HS256 JWT signed with `JWT_SECRET`, no external service involved. This task also provisions an account, so the farm must be up. -5. **Point the provider/tests at it**: `ABLY_URL=http://localhost:/api/v1`, `ABLY_ACCOUNT_TOKEN=`, then `TF_ACC=1 make testacc` (or `go test`). -6. **Region caveat**: the queue resource validator only accepts `us-east-1-a` and `eu-west-1-a`, and the e2e tests use `us-east-1-a`; the local stack must accept that region or queue creation fails. - -Blockers that stop this being a cold-start-unattended path today, and why Tier 1 is the agent's real loop: - -- The farm pulls images from a private ECR registry whose login expires every 12 hours and needs AWS creds for the Ably prod account. -- The farm builds private Go modules over SSH and needs an SSH key with `ably-labs` org access. -- First farm start compiles Go roles and routinely exceeds the default 30s health timeout (hence `--daemon-timeout=60`). -- The website needs `mise`/`asdf` toolchains, the `ably-env` CLI, assorted `.env` secrets, and the private `cartography` gem (skippable). -- Only one farm can run at a time. - -None of these are things an agent can clear on its own, which is why Tier 1 is the loop we hand the agent and Tier 3 is a warm environment a human sets up. - -### Keeping the fake honest - -The risk with Tier 1 is a fake that drifts from reality and passes anyway. Two mitigations, in order of preference: derive the fake's response shapes from real recorded staging responses (record once, hand-curate into the fake), and add a periodic contract check that diffs the fake's responses against the Tier 2 staging suite. The fake proves the provider is internally consistent; only staging proves it matches production. The two tiers together are the credible loop. - -### What to build - -- An `httptest`-backed fake Control API in `internal/provider/`, plus a `make test` target that runs the hermetic suite with zero env. This is the agent's inner loop and the highest-value item from this whole exercise. -- A documented, scripted Tier 3 bring-up (a `make local-stack` that sequences farm-then-website with the Redis-port and ordering caveats baked in), accepting it still needs the human-gated prerequisites cleared once. -- A contract check tying the fake to staging so it cannot quietly lie. - -### Status: Tier 1 is built (2026-06-09) - -The hermetic fake exists at `internal/provider/fake_control_api_test.go` and `make test` now runs the entire provider acceptance suite against it with no credentials and no network, green. `make testacc` is unchanged and still hits a real Control API when `TF_ACC` is set. The fake is ~500 lines of stateful in-memory CRUD over the endpoints in `control/*.go`. - -Two things the build surfaced that are worth carrying forward: - -- **The `dev_overrides` trap is real and it bit us immediately.** The repo pins the provider source to the `ably/ably` namespace, which the in-process reattach factory (keyed by the bare type `ably`) cannot satisfy, so the suite relies on `dev_overrides`. A stale `dev_overrides` in `~/.terraformrc` silently ran an old installed binary instead of the code under test, so edits appeared to do nothing. `TestMain` now defends against this: it builds the provider from current source into a temp dir and writes its own clean `dev_overrides` config, guaranteeing the tests exercise the current code. This is the same class of silent-staleness failure the strategy warns about, and an agent would have been badly misled by it. -- **The fake's honesty ledger has started.** Two real-API behaviours had to be encoded so the provider's computed attributes did not drift: namespaces always return `batchingEnabled`/`conflationEnabled` (default false), and HTTP-family rule targets default `format` to `json`. These are exactly the "keep the fake honest" items; each is a small, documented divergence the staging suite (Tier 2) should eventually be diffed against. - -### Open questions - -- Is it worth investing to make Tier 3 cold-startable in CI (caching ECR creds, vendoring the private modules), or is Tier 1 plus the existing staging Tier 2 enough? My instinct is the latter: the staging suite already gives real-behaviour proof in CI, so Tier 3's value is local debugging, not the automated loop. -- Could we issue a long-lived staging account token for local use, so a developer or agent gets a real round-trip by pointing at staging with no local stack at all? That may beat Tier 3 for most cases. -- Who owns the fake's fidelity, and how often does the contract check run? - -## 8. Reference: load-bearing files - -- `swagger/v1/swagger.yaml` (website repo): `oneOf`/discriminator ~1331-1481, pkcs12 multipart ~266-314 -- `control/client.go`: retryablehttp transport, `retryPolicy`, `WithHTTPClient` ~44-132 -- `control/types.go`: `RuleResponse` 479-491, `NamespacePost`/`NamespacePatch` 139-172 -- `control/rules.go`: `CreateRule`/`UpdateRule` taking `any` 22, 38 -- `internal/provider/rules.go`: `GetPlanRule`/`GetRuleResponse` switch cases, plus `modifiers.go` and `models.go` diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index a6b1453..5e354a7 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -66,12 +66,41 @@ stays hand-written. 4. Write the resource shim in `internal/provider/` (see "Porting" below for the pattern): a `Schema()` that adopts the generated schema, the CRUD methods delegating to the `control` client, and `Metadata`/`ImportState`. + For a moderation or before-publish rule, take the shared plumbing in + `internal/provider/before_publish_rules.go` instead of writing CRUD by hand: + supply the model plus a create-body function and a response-mapping function, + and delegate the four CRUD methods to `beforePublishCRUD`. Reference example: + `resource_ably_rule_tisane.go`. Do **not** copy a webhook rule for these + families; the generic `AblyRule` plumbing bakes in `source` and + `request_mode`, which these rules do not have and the API rejects. 5. Register the resource in `internal/provider/provider.go`. 6. Add an example under `examples/resources/`, a template under `templates/resources/`, and run `tfplugindocs` to generate the doc. 7. Add an acceptance test and a unit test for any preserve-from-plan / write-only handling. Run `make test`. +## Adding a data source + +The Control API has no fetch-by-ID endpoint for apps, keys, namespaces or queues, +only list endpoints, so every entity has a plural data source generated from its +list response and a singular one that lists and filters locally. + +1. Add the read path to the `data_sources` block in `codegen/generator_config.yml` + and run `make generate`. That produces + `internal/provider/codegen/datasource_/`. +2. Write the data source in `internal/provider/data_source_ably_.go`: + a model mirroring the generated element attributes, the plural data source + adopting the generated schema, and the singular one built with + `elementAttributes` (see `data_sources.go`) so both serve the same generated + attribute set. Use `findOne` for the lookup: it enforces exactly one of + id/name and refuses to guess when a name matches more than one record. +3. Register both in `DataSources()` in `internal/provider/provider.go`. +4. Name them after the existing resource, not the spec. The key data sources are + `ably_api_key`/`ably_api_keys` because the resource is `ably_api_key`, and the + key permissions map is `capabilities` for the same reason. +5. Add an example under `examples/data-sources/`, a template under + `templates/data-sources/`, run `tfplugindocs`, and add an acceptance test. + ## Porting a resource onto generated code The reference example is `ably_rule_bodyguard` diff --git a/Makefile b/Makefile index 192b069..9aa53f8 100644 --- a/Makefile +++ b/Makefile @@ -66,15 +66,17 @@ generate: go run ./codegen/ruletypesgen go run github.com/hashicorp/terraform-plugin-codegen-framework/cmd/tfplugingen-framework@v0.4.1 generate resources --input codegen/spec.json --output internal/provider/codegen go run github.com/hashicorp/terraform-plugin-codegen-framework/cmd/tfplugingen-framework@v0.4.1 generate resources --input codegen/rules_spec.json --output internal/provider/codegen + # Data sources (Track A only): the list endpoints, plus /me. + go run github.com/hashicorp/terraform-plugin-codegen-framework/cmd/tfplugingen-framework@v0.4.1 generate data-sources --input codegen/spec.json --output internal/provider/codegen gofmt -w internal/provider/codegen -# Refresh the vendored Control API spec from the public ably/docs repo and +# Refresh the vendored Control API spec from the published copy on ably.com and # re-apply any local fixes (codegen/spec-fixes.patch, if present), then # regenerate. Never copy the upstream spec over codegen/control-api.yaml by # hand: that silently reverts the fixes and the generators skip the affected # attributes without erroring. Pass SPEC_SRC= to use a local ably/docs -# checkout instead of fetching from GitHub. -SPEC_URL=https://raw.githubusercontent.com/ably/docs/main/static/open-specs/control-v1.yaml +# checkout instead of fetching over the network. +SPEC_URL=https://ably.com/docs/open-specs/control-v1.yaml refresh-spec: ifdef SPEC_SRC cp $(SPEC_SRC) codegen/control-api.yaml diff --git a/codegen/README.md b/codegen/README.md index db31dd6..ef92814 100644 --- a/codegen/README.md +++ b/codegen/README.md @@ -1,22 +1,22 @@ # Code generation This directory holds the inputs for generating Terraform schema and model code -from the Ably Control API's OpenAPI spec, the first step of the strategy in -[`../CODEGEN_STRATEGY.md`](../CODEGEN_STRATEGY.md). +from the Ably Control API's OpenAPI spec. ## What's here -- `control-api.yaml` — a vendored snapshot of the Control API OpenAPI spec. We - source it from the `ably/docs` repo (`static/open-specs/control-v1.yaml`), - which is the published, description-rich version (~1,160 field descriptions - versus ~150 in the `ably/website` rswag output). Generating from it gives the - generated schemas correct attribute documentation. We vendor a copy so - generation is self-contained and runnable in CI without checking out that - repo. Refresh it with `make refresh-spec`, which fetches the latest spec - from the public `ably/docs` repo (or copies from a local checkout with - `SPEC_SRC=`) and re-applies any local fixes. Never copy the upstream - file over this one by hand: that silently reverts the fixes, and the - generators skip the affected attributes without erroring. +- `control-api.yaml` — a vendored snapshot of the Control API OpenAPI spec, + taken from the copy the docs site publishes at + . That is the same file as + `static/open-specs/control-v1.yaml` in `ably/docs`, as served, so we track what + is published rather than what is merged. It is the description-rich version + (~1,160 field descriptions versus ~150 in the `ably/website` rswag output), + which is what gives the generated schemas usable attribute documentation. We + vendor a copy so generation is self-contained and runnable in CI. Refresh it + with `make refresh-spec`, which fetches the published spec (or copies from a + local checkout with `SPEC_SRC=`) and re-applies any local fixes. Never + copy the upstream file over this one by hand: that silently reverts the fixes, + and the generators skip the affected attributes without erroring. - `spec-fixes.patch` — our local fixes to the vendored spec, re-applied by `make refresh-spec` when the file exists. There are none at present (the last one, `conflationEnabled` missing `type: boolean` in the namespace @@ -63,24 +63,41 @@ assert that regeneration produces no diff. This is deliberately limited right now: -- **Two tracks.** Simple resources (`app`, `namespace`, `queue`) generate from - the OpenAPI spec. The integration rules use an OpenAPI `oneOf` + discriminator - that `tfplugingen-openapi` cannot handle, so the moderation and before-publish - rule families are generated from the in-repo `control` types instead via - `ruletypesgen` (see the strategy doc). The webhook/firehose rule families are - not generated yet. +- **Two tracks.** Simple resources (`app`, `namespace`, `queue`) and the data + sources generate from the OpenAPI spec. The integration rules use an OpenAPI + `oneOf` + discriminator that `tfplugingen-openapi` cannot handle, so the + moderation and before-publish rule families are generated from the in-repo + `control` types instead via `ruletypesgen`. The webhook/firehose rule families + are not generated yet. - **Schema + model only.** The tools do not emit CRUD wiring. All wiring to the `control` client stays hand-written and is not generated here. - **Both tools are tech preview.** `tfplugingen-openapi` last shipped v0.3.0 (Jan 2024). It works on our spec today; we are not betting anything load bearing on a future release. -- **The generated code is wired into one live resource so far.** - `ably_rule_bodyguard` is ported onto it; the rest of the generated packages - are committed as the reviewable output of the pipeline. Retrofitting the - remaining resources is a separate, deliberate step, partly because some - diverge from the spec shape on purpose (e.g. `queue` flattens the API's - nested `amqp`/`stomp` objects into flat attributes). See the Phase 1 findings - in the strategy doc. + +## Don't port app, namespace or queue onto the generated schemas yet + +Every rule resource and every data source is on generated schema. The three +simple resources are not, and the generated versions of them are not +contract-complete: adopting one as-is is a breaking change. What each would need +reconciling first (all metadata the spec cannot express): + +- **namespace**: no `identified` attribute, and a bare `authenticated` bool + defaulting to `false`. The hand-written schema carries the whole INF-7589 + migration (canonical `identified`, deprecated `authenticated` alias, alias plan + modifiers, `ConflictsWith`, deprecation message) that a port must preserve. + `batching_interval` also gains a spurious default of 20, where the hand-written + schema has null plus an `AtLeast(0)` validator, and `id`/`app_id` lose + `RequiresReplace`. +- **queue**: every `RequiresReplace` is lost (`app_id`, `name`, `ttl`, + `max_length`, `region`), and the Control API has no queue-update endpoint, so a + ported resource would plan in-place updates it cannot execute. The resource also + flattens the API's nested `amqp`/`stomp` objects into flat attributes on + purpose, which the generator faithfully un-flattens. +- **app**: generated `created`/`modified` are Int64 where the hand-written schema + has RFC3339 strings, which breaks decoding of every existing state file. + `UseStateForUnknown` on `id`/`account_id`/`created` and the + `status`/`tls_only`/`apns_use_sandbox_endpoint` defaults are all dropped. ## Known per-resource quirks (encoded in `generator_config.yml`) diff --git a/codegen/control-api.yaml b/codegen/control-api.yaml index 5a94920..3e77e81 100644 --- a/codegen/control-api.yaml +++ b/codegen/control-api.yaml @@ -8,7 +8,7 @@ info:

Detailed information on using this API can be found in the Ably Control API docs.

- The OpenAPI specification file used to generate this REST API is available to view here. + The OpenAPI specification file used to generate this REST API is available to view here. servers: - url: https://control.ably.net/v1 paths: @@ -6689,8 +6689,10 @@ TOfReTlUQzgpXRW5h3n2LVXbXQhPGcVitb88Cm2R8cxQwgB1VncM8yvmKhREo2tz properties: name: type: string - description: A friendly name for your queue. - example: My queue + maxLength: 64 + pattern: "^[A-Za-z0-9_-]+$" + description: A friendly name for your queue. May only contain letters, numbers, hyphens and underscores, up to 64 characters. + example: my-queue ttl: type: integer description: TTL in minutes. @@ -6715,7 +6717,7 @@ TOfReTlUQzgpXRW5h3n2LVXbXQhPGcVitb88Cm2R8cxQwgB1VncM8yvmKhREo2tz id: type: string description: The ID of the Ably queue - example: 28AB6w:us-east-1-a:My queue + example: 28AB6w:us-east-1-a:my-queue appId: type: string description: The Ably application ID. @@ -6723,7 +6725,7 @@ TOfReTlUQzgpXRW5h3n2LVXbXQhPGcVitb88Cm2R8cxQwgB1VncM8yvmKhREo2tz name: type: string description: The friendly name of the queue. - example: My queue + example: my-queue region: type: string description: The data center region for the queue. @@ -6739,7 +6741,7 @@ TOfReTlUQzgpXRW5h3n2LVXbXQhPGcVitb88Cm2R8cxQwgB1VncM8yvmKhREo2tz queueName: type: string description: Name of the Ably queue. - example: 28AB6w:My queue + example: 28AB6w:my-queue stomp: type: object additionalProperties: false @@ -6755,7 +6757,7 @@ TOfReTlUQzgpXRW5h3n2LVXbXQhPGcVitb88Cm2R8cxQwgB1VncM8yvmKhREo2tz destination: type: string description: Destination queue. - example: /amqp/queue/28AB6w:My queue + example: /amqp/queue/28AB6w:my-queue state: type: string description: The current state of the queue. diff --git a/codegen/generator_config.yml b/codegen/generator_config.yml index fd8d452..237a209 100644 --- a/codegen/generator_config.yml +++ b/codegen/generator_config.yml @@ -1,6 +1,34 @@ provider: name: ably +# Data sources come from the list endpoints, because the Control API has no +# GET-by-ID for apps, keys, namespaces or queues (only rules have one). So each +# entity generates a plural data source: the parent path parameter as an input, +# and a computed set of whole objects. The singular by-id-or-name data sources +# lift their attributes from these generated sets at runtime, so there is one +# source of truth per entity; see internal/provider/data_sources.go. +data_sources: + apps: + read: + path: /accounts/{account_id}/apps + method: GET + keys: + read: + path: /apps/{app_id}/keys + method: GET + namespaces: + read: + path: /apps/{app_id}/namespaces + method: GET + queues: + read: + path: /apps/{app_id}/queues + method: GET + me: + read: + path: /me + method: GET + resources: app: create: diff --git a/codegen/rules_spec.json b/codegen/rules_spec.json index d32eccb..4005799 100644 --- a/codegen/rules_spec.json +++ b/codegen/rules_spec.json @@ -571,7 +571,19 @@ "description": "A map of moderation categories to threshold levels (0-3). Messages scoring above the threshold for any category will be rejected.", "element_type": { "int64": {} - } + }, + "validators": [ + { + "custom": { + "imports": [ + { + "path": "github.com/hashicorp/terraform-plugin-framework-validators/mapvalidator" + } + ], + "schema_definition": "mapvalidator.SizeAtLeast(1)" + } + } + ] }, "name": "thresholds" }, @@ -866,7 +878,19 @@ "description": "A map of moderation categories to threshold levels (0-7). Messages scoring above the threshold for any category will be rejected.", "element_type": { "int64": {} - } + }, + "validators": [ + { + "custom": { + "imports": [ + { + "path": "github.com/hashicorp/terraform-plugin-framework-validators/mapvalidator" + } + ], + "schema_definition": "mapvalidator.SizeAtLeast(1)" + } + } + ] }, "name": "thresholds" } @@ -1142,7 +1166,19 @@ "description": "A map of moderation categories to threshold levels (1-3). Messages scoring above the threshold for any category will be rejected.", "element_type": { "int64": {} - } + }, + "validators": [ + { + "custom": { + "imports": [ + { + "path": "github.com/hashicorp/terraform-plugin-framework-validators/mapvalidator" + } + ], + "schema_definition": "mapvalidator.SizeAtLeast(1)" + } + } + ] }, "name": "thresholds" } @@ -1590,6 +1626,7 @@ "string": { "computed_optional_required": "optional", "description": "The value of the header.", + "sensitive": true, "validators": [ { "custom": { @@ -1836,18 +1873,11 @@ "name": "source", "single_nested": { "attributes": [ - { - "name": "channel_filter", - "string": { - "computed_optional_required": "required", - "description": "This field allows you to filter your rule based on a regular expression that is matched against the complete channel name. Leave this empty if you want the rule to apply to all channels." - } - }, { "name": "type", "string": { "computed_optional_required": "required", - "description": "Ably currently supports the following sources for all rule types, in both single and batch mode: `channel.message`, `channel.presence`, `channel.lifecycle` and `channel.occupancy`. If the source `channel.message` is selected, you receive notifications when messages are published on a channel. If the source `channel.presence` is selected, you receive notifications of presence events when clients enter, update their data, or leave channels. If the source `channel.lifecycle` is selected, you receive notifications of channel lifecycle events, such as when a channel is created (following the first client attaching to this channel) or discarded (when there are no more clients attached to the channel). If the source `channel.occupancy` is selected, you receive notifications of occupancy events, which relate to the number and type of occupants in the channel.", + "description": "The source type. Before-publish rules act on chat messages, so `chat.message` is the only supported value.", "validators": [ { "custom": { @@ -1856,14 +1886,27 @@ "path": "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" } ], - "schema_definition": "stringvalidator.LengthAtLeast(1)" + "schema_definition": "stringvalidator.OneOf(\"chat.message\")" } } ] } } ], - "computed_optional_required": "optional" + "computed_optional_required": "computed_optional", + "description": "The source of messages this rule applies to. Optional: the Control API assigns a default source when it is omitted.", + "plan_modifiers": [ + { + "custom": { + "imports": [ + { + "path": "github.com/hashicorp/terraform-plugin-framework/resource/schema/objectplanmodifier" + } + ], + "schema_definition": "objectplanmodifier.UseStateForUnknown()" + } + } + ] } }, { diff --git a/codegen/ruletypesgen/main.go b/codegen/ruletypesgen/main.go index 42dbb2d..df462f2 100644 --- a/codegen/ruletypesgen/main.go +++ b/codegen/ruletypesgen/main.go @@ -2,9 +2,9 @@ // integration-rule families that cannot be generated from the OpenAPI spec. // // The Control API models rules as a oneOf + discriminator union, which -// tfplugingen-openapi cannot handle (see CODEGEN_STRATEGY.md). Instead we drive -// generation from the in-repo control rule types, which are already the -// curated, per-family-correct model: the moderation and before-publish +// tfplugingen-openapi cannot handle. Instead we drive generation from the +// in-repo control rule types, which are already the curated, +// per-family-correct model: the moderation and before-publish // families correctly drop the webhook source/request_mode fields and carry // before_publish_config/invocation_mode/chat_room_filter instead. // @@ -60,7 +60,12 @@ var rules = []rule{ // "url" sensitive because theirs embed database credentials, but listing it // here would also mask webhook endpoint URLs. var sensitive = map[string]bool{ - "api_key": true, + "api_key": true, + // The complete API key including its secret, as returned by GET + // /apps/{app_id}/keys. The hand-written ably_api_key resource already marks + // it sensitive; the ably_api_keys data source has to agree or it prints + // secrets. + "key": true, "token": true, "password": true, "secret_access_key": true, @@ -72,6 +77,23 @@ var sensitive = map[string]bool{ "fcm_service_account": true, } +// sensitivePaths marks credential-bearing attributes whose own name is too +// generic to put in `sensitive` above. Keys are dotted path suffixes, matched +// against an attribute's path within its rule, so only the intended attribute is +// affected wherever its parent block happens to sit (a header value is +// target.headers.value today). +// +// Webhook header values routinely carry an authorization token (the spec's own +// example header name is "Authorization"), so they should not appear in plan +// output. "value" on its own would mark every future attribute of that name. +// +// Note the hand-written webhook rules (ably_rule_http and friends, schemas in +// rules.go) do NOT mark their header values sensitive. Bringing them into line is +// a user-visible change to GA resources and belongs in its own change. +var sensitivePaths = map[string]bool{ + "headers.value": true, +} + // customExpr is a code expression plus the imports it needs, used to emit // validators, defaults and plan modifiers into the Provider Code Spec. type customExpr struct { @@ -83,6 +105,7 @@ type customExpr struct { // express. Keyed by snake_case attribute name (top-level or nested). type override struct { mode string // overrides computed_optional_required when set + description string // overrides the spec-sourced description when set staticDefault any // sets a static default when non-nil allowEmpty bool // suppresses the LengthAtLeast(1) validator validators []customExpr @@ -92,7 +115,9 @@ type override struct { const ( pkgStringValidator = "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" pkgInt64Validator = "github.com/hashicorp/terraform-plugin-framework-validators/int64validator" + pkgMapValidator = "github.com/hashicorp/terraform-plugin-framework-validators/mapvalidator" pkgStringPlanModifier = "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + pkgObjectPlanModifier = "github.com/hashicorp/terraform-plugin-framework/resource/schema/objectplanmodifier" pkgPlanModifiers = "github.com/ably/terraform-provider-ably/internal/provider/planmodifiers" pkgRegexp = "regexp" ) @@ -120,6 +145,61 @@ var attrOverrides = map[string]override{ // (verified against the live API, 2026-07-20: create with "" and update // non-empty -> "" both persist and read back as ""). "channel_filter": {allowEmpty: true}, + // An explicit empty thresholds map cannot round-trip: the control types tag + // the field omitempty, so `thresholds = {}` is sent as absent, comes back + // absent, and reads as null, which aborts the apply with an opaque + // "inconsistent result after apply". Same reasoning as the LengthAtLeast(1) + // on optional strings below: reject it at plan time with a message that says + // what is wrong. + "thresholds": {validators: []customExpr{{[]string{pkgMapValidator}, "mapvalidator.SizeAtLeast(1)"}}}, + // Before-publish AWS Lambda is the only generated rule that takes a source, + // and the API does not treat it like the webhook/firehose source whose spec + // schema it shares ($ref: rule_source). Two differences, both found on + // staging (2026-08-17) and neither documented: + // + // 1. The API defaults the source when you omit it, reading back as + // {"type": "chat.message"}. Optional alone therefore fails with + // "produced inconsistent result after apply: .source: was null, but now + // ...", so it has to be computed as well. + // 2. It validates against #/components/schemas/chat_message_rule_source, + // which does not exist anywhere in the spec: it rejects the documented + // channel.message ("isn't part of the enum") and rejects a channelFilter + // outright ("does not define properties: channelFilter"). So the shape is + // type-only (see control.ChatMessageRuleSource) and the inherited + // rule_source description, which tells you to use channel.message, is + // actively wrong. + // + // TODO(INF-7992): delete the source and type overrides below once ably/docs + // documents chat_message_rule_source and the before-publish rules stop + // $ref-ing rule_source. The enum, the description and the type-only shape all + // generate from the spec at that point; only the computed mode and the plan + // modifier need to stay, because those describe API behaviour rather than + // schema. chat.message is the only value verified to work, so it is the only + // one allowed: being too permissive just moves the failure to apply time, + // which is what this validator exists to prevent. + // UseStateForUnknown is what stops the server-assigned value being replanned + // forever: with the block computed and the config empty, every subsequent plan + // would otherwise show `source -> (known after apply)` and never converge. + "source": { + mode: "computed_optional", + description: "The source of messages this rule applies to. Optional: the Control API assigns a default source when it is omitted.", + planModifiers: []customExpr{{[]string{pkgObjectPlanModifier}, "objectplanmodifier.UseStateForUnknown()"}}, + }, + "type": { + description: "The source type. Before-publish rules act on chat messages, so `chat.message` is the only supported value.", + validators: []customExpr{{[]string{pkgStringValidator}, `stringvalidator.OneOf("chat.message")`}}, + }, +} + +// isSensitivePath reports whether an attribute path matches a sensitivePaths +// suffix, so "headers.value" covers "target.headers.value". +func isSensitivePath(path string) bool { + for suffix := range sensitivePaths { + if path == suffix || strings.HasSuffix(path, "."+suffix) { + return true + } + } + return false } // applyOverride mutates an attribute's type map with any configured metadata. @@ -131,6 +211,9 @@ func applyOverride(name string, m map[string]any) { if ov.mode != "" { m["computed_optional_required"] = ov.mode } + if ov.description != "" { + m["description"] = ov.description + } if ov.staticDefault != nil { m["default"] = map[string]any{"static": ov.staticDefault} } @@ -170,7 +253,7 @@ func main() { resources := make([]map[string]any, 0, len(rules)) for _, r := range rules { props := schemaProps(schemas, r.specSchema) - attrs := attrsFromStruct(reflect.TypeOf(r.post), props) + attrs := attrsFromStruct(reflect.TypeOf(r.post), props, "") // Every rule resource carries the same envelope: a computed id and the // required parent app_id. These are not on the create body. idMap := map[string]any{"computed_optional_required": "computed", "description": "The rule ID."} @@ -247,6 +330,10 @@ func markSensitive(attrs any) { } markSensitive(asMap(attr["single_nested"])["attributes"]) markSensitive(asMap(asMap(attr["list_nested"])["nested_object"])["attributes"]) + // The generated data sources model list endpoints as sets, so a secret + // inside one (the complete API key on ably_api_keys, say) is only + // reachable through set_nested. + markSensitive(asMap(asMap(attr["set_nested"])["nested_object"])["attributes"]) } } @@ -277,7 +364,7 @@ func schemaProps(schemas map[string]any, name string) map[string]any { // attrsFromStruct reflects a struct type into Provider Code Spec attributes, // pulling each field's description from the matching OpenAPI properties map. -func attrsFromStruct(t reflect.Type, props map[string]any) []map[string]any { +func attrsFromStruct(t reflect.Type, props map[string]any, path string) []map[string]any { var attrs []map[string]any for i := 0; i < t.NumField(); i++ { f := t.Field(i) @@ -291,6 +378,10 @@ func attrsFromStruct(t reflect.Type, props map[string]any) []map[string]any { continue } desc := description(props, jsonName) + fieldPath := name + if path != "" { + fieldPath = path + "." + name + } ft := f.Type optional := omitempty @@ -310,7 +401,7 @@ func attrsFromStruct(t reflect.Type, props map[string]any) []map[string]any { if desc != "" { s["description"] = desc } - if sensitive[name] { + if sensitive[name] || isSensitivePath(fieldPath) { s["sensitive"] = true } applyOverride(name, s) @@ -375,18 +466,22 @@ func attrsFromStruct(t reflect.Type, props map[string]any) []map[string]any { case reflect.Struct: sn := map[string]any{ "computed_optional_required": mode, - "attributes": attrsFromStruct(ft, childProps(props, jsonName)), + "attributes": attrsFromStruct(ft, childProps(props, jsonName), fieldPath), } if desc != "" { sn["description"] = desc } + // Nested blocks take overrides too: a block the API defaults has to be + // computed as well as optional, which the Go type cannot express (see + // the source entry in attrOverrides). + applyOverride(name, sn) attr["single_nested"] = sn case reflect.Slice: elem := ft.Elem() if elem.Kind() == reflect.Struct { attr["list_nested"] = map[string]any{ "computed_optional_required": mode, - "nested_object": map[string]any{"attributes": attrsFromStruct(elem, itemProps(props, jsonName))}, + "nested_object": map[string]any{"attributes": attrsFromStruct(elem, itemProps(props, jsonName), fieldPath)}, } } else { attr["list"] = map[string]any{ @@ -405,6 +500,7 @@ func attrsFromStruct(t reflect.Type, props map[string]any) []map[string]any { if desc != "" { m["description"] = desc } + applyOverride(name, m) attr["map"] = m default: // Fail loudly rather than emitting an incomplete schema: a silent diff --git a/codegen/spec.json b/codegen/spec.json index 5d62b59..30e9f8f 100644 --- a/codegen/spec.json +++ b/codegen/spec.json @@ -1,4 +1,681 @@ { + "datasources": [ + { + "name": "apps", + "schema": { + "attributes": [ + { + "name": "account_id", + "string": { + "computed_optional_required": "required", + "description": "The account ID for which to retrieve the associated applications." + } + }, + { + "name": "apps", + "set_nested": { + "computed_optional_required": "computed", + "nested_object": { + "attributes": [ + { + "name": "_links", + "single_nested": { + "computed_optional_required": "computed", + "description": "A link self-referencing the app that has been created." + } + }, + { + "name": "account_id", + "string": { + "computed_optional_required": "computed", + "description": "The ID of your Ably account." + } + }, + { + "name": "apns_auth_type", + "string": { + "computed_optional_required": "computed", + "description": "The Apple Push Notification service authentication type. Use `certificate` for p12 certificate-based auth, or `token` for p8 key-based auth." + } + }, + { + "bool": { + "computed_optional_required": "computed", + "description": "Whether a certificate-based APNs configuration is currently active." + }, + "name": "apns_certificate_configured" + }, + { + "name": "apns_issuer_key", + "string": { + "computed_optional_required": "computed", + "description": "The Team ID from the Apple Developer account. Used as the issuer when generating APNs authentication tokens." + } + }, + { + "bool": { + "computed_optional_required": "computed", + "description": "Whether a token-based (p8) APNs signing key is currently configured." + }, + "name": "apns_signing_key_configured" + }, + { + "name": "apns_signing_key_id", + "string": { + "computed_optional_required": "computed", + "description": "The 10-character Key ID from the Apple Developer portal, associated with the `.p8` signing key." + } + }, + { + "name": "apns_topic_header", + "string": { + "computed_optional_required": "computed", + "description": "The app's bundle ID (for example, `com.example.myapp`). Used as the `apns-topic` header when sending push notifications." + } + }, + { + "bool": { + "computed_optional_required": "computed", + "description": "Use the Apple Push Notification service sandbox endpoint." + }, + "name": "apns_use_sandbox_endpoint" + }, + { + "int64": { + "computed_optional_required": "computed", + "description": "Unix timestamp representing the date and time of creation of the application." + }, + "name": "created" + }, + { + "name": "fcm_project_id", + "string": { + "computed_optional_required": "computed", + "description": "The unique identifier for the Firebase Cloud Messaging (FCM) project. This ID is used to specify the Firebase project when configuring FCM or other Firebase services." + } + }, + { + "bool": { + "computed_optional_required": "computed", + "description": "Whether Firebase Cloud Messaging (FCM) service account credentials are currently configured." + }, + "name": "fcm_service_account_configured" + }, + { + "name": "id", + "string": { + "computed_optional_required": "computed", + "description": "The application ID." + } + }, + { + "int64": { + "computed_optional_required": "computed", + "description": "Unix timestamp representing the date and time of last modification of the application." + }, + "name": "modified" + }, + { + "name": "name", + "string": { + "computed_optional_required": "computed", + "description": "The application name." + } + }, + { + "name": "status", + "string": { + "computed_optional_required": "computed", + "description": "The status of the application. Can be `enabled` or `disabled`. Enabled means available to accept inbound connections and all services are available." + } + }, + { + "bool": { + "computed_optional_required": "computed", + "description": "Enforce TLS for all connections. This setting overrides any channel setting." + }, + "name": "tls_only" + } + ] + } + } + } + ] + } + }, + { + "name": "keys", + "schema": { + "attributes": [ + { + "name": "app_id", + "string": { + "computed_optional_required": "required", + "description": "The application ID." + } + }, + { + "name": "keys", + "set_nested": { + "computed_optional_required": "computed", + "nested_object": { + "attributes": [ + { + "name": "app_id", + "string": { + "computed_optional_required": "computed", + "description": "The Ably application ID which this key is associated with." + } + }, + { + "map": { + "computed_optional_required": "computed", + "description": "The capabilities that this key has. More information on capabilities can be found in the Ably \u003ca href=\"https://ably.com/docs/auth/capabilities\"\u003ecapabilities docs\u003c/a\u003e.", + "element_type": { + "list": { + "element_type": { + "string": {} + } + } + } + }, + "name": "capability" + }, + { + "int64": { + "computed_optional_required": "computed", + "description": "Unix timestamp representing the date and time of creation of the key." + }, + "name": "created" + }, + { + "name": "id", + "string": { + "computed_optional_required": "computed", + "description": "The key ID." + } + }, + { + "name": "key", + "string": { + "computed_optional_required": "computed", + "description": "The complete API key including API secret.", + "sensitive": true + } + }, + { + "int64": { + "computed_optional_required": "computed", + "description": "Unix timestamp representing the date and time of the last modification of the key." + }, + "name": "modified" + }, + { + "name": "name", + "string": { + "computed_optional_required": "computed", + "description": "The name for your API key. This is a friendly name for your reference." + } + }, + { + "bool": { + "computed_optional_required": "computed", + "description": "If `true`, tokens issued by this key can be individually revoked. See the \u003ca href=\"https://ably.com/docs/auth/revocation\"\u003etoken revocation\u003c/a\u003e documentation for more details." + }, + "name": "revocable_tokens" + }, + { + "int64": { + "computed_optional_required": "computed", + "description": "The status of the key. `0` is enabled, `1` is revoked." + }, + "name": "status" + } + ] + } + } + } + ] + } + }, + { + "name": "me", + "schema": { + "attributes": [ + { + "name": "account", + "single_nested": { + "attributes": [ + { + "name": "id", + "string": { + "computed_optional_required": "computed", + "description": "The account ID." + } + }, + { + "name": "name", + "string": { + "computed_optional_required": "computed", + "description": "The name of the account." + } + } + ], + "computed_optional_required": "computed" + } + }, + { + "name": "token", + "single_nested": { + "attributes": [ + { + "list": { + "computed_optional_required": "computed", + "description": "An array containing the access capabilities associated with the access token.", + "element_type": { + "string": {} + } + }, + "name": "capabilities" + }, + { + "name": "expires_at", + "string": { + "computed_optional_required": "computed", + "description": "The date and time at which the access token expires, in ISO 8601 format. A value of `null` indicates the token does not expire." + } + }, + { + "name": "id", + "string": { + "computed_optional_required": "computed", + "description": "The token ID." + } + }, + { + "name": "last_used_at", + "string": { + "computed_optional_required": "computed", + "description": "The date and time at which the access token was last used to authenticate a Control API request, in ISO 8601 format. Updated at most once per 10-minute window. A value of `null` indicates the token has not been used since tracking began." + } + }, + { + "name": "name", + "string": { + "computed_optional_required": "computed", + "description": "The friendly name for the token." + } + } + ], + "computed_optional_required": "computed" + } + }, + { + "name": "user", + "single_nested": { + "attributes": [ + { + "name": "email", + "string": { + "computed_optional_required": "computed", + "description": "Email address of the user associated with the account." + } + }, + { + "int64": { + "computed_optional_required": "computed", + "description": "The user ID associated with the account." + }, + "name": "id" + } + ], + "computed_optional_required": "computed" + } + } + ] + } + }, + { + "name": "namespaces", + "schema": { + "attributes": [ + { + "name": "app_id", + "string": { + "computed_optional_required": "required", + "description": "The application ID." + } + }, + { + "name": "namespaces", + "set_nested": { + "computed_optional_required": "computed", + "nested_object": { + "attributes": [ + { + "name": "app_id", + "string": { + "computed_optional_required": "computed", + "description": "The ID of the application the namespace belongs to." + } + }, + { + "bool": { + "computed_optional_required": "computed", + "description": "If `true`, clients will not be permitted to use (including to attach, publish, or subscribe) any channels within this namespace unless they are identified, that is, authenticated using a client ID. See the documentation for \u003ca href=\"https://ably.com/docs/auth/identified-clients\"\u003eidentified clients\u003c/a\u003e for more details." + }, + "name": "authenticated" + }, + { + "bool": { + "computed_optional_required": "computed", + "description": "If `true`, channels within this namespace will start batching inbound messages instead of sending them out immediately to subscribers." + }, + "name": "batching_enabled" + }, + { + "int64": { + "computed_optional_required": "computed", + "description": "If `true`, the batching interval, configurable from 20ms to 1s, temporarily holds and aggregates incoming messages. These messages are then combined and delivered as a single batch once the interval elapses or a size limit is reached (before the interval elapses)." + }, + "name": "batching_interval" + }, + { + "bool": { + "computed_optional_required": "computed", + "description": "If `true`, enables \u003ca href=\"https://ably.com/docs/messages#conflation\"\u003econflation\u003c/a\u003e for channels within this namespace. Conflation will aggregate published messages for a set period of time and evaluate them against a conflation key. Only the most recent message that satisfies the key will be sent to subscribers at the end of the conflation interval." + }, + "name": "conflation_enabled" + }, + { + "int64": { + "computed_optional_required": "computed", + "description": "The interval in milliseconds over which messages are held and evaluated for conflation." + }, + "name": "conflation_interval" + }, + { + "name": "conflation_key", + "string": { + "computed_optional_required": "computed", + "description": "The \u003ca href=\"https://ably.com/docs/messages#routing\"\u003ekey\u003c/a\u003e used to determine which messages should be conflated." + } + }, + { + "int64": { + "computed_optional_required": "computed", + "description": "Unix timestamp representing the date and time of creation of the namespace." + }, + "name": "created" + }, + { + "bool": { + "computed_optional_required": "computed", + "description": "If `true`, messages received on a channel will contain a unique `timeserial` that can be referenced by later messages for use with message interactions." + }, + "name": "expose_timeserial" + }, + { + "name": "id", + "string": { + "computed_optional_required": "computed", + "description": "The namespace or channel name that the channel rule will apply to. For example, if you specify `namespace` the namespace will be set to `namespace` and will match with channels `namespace:*` and `namespace`." + } + }, + { + "int64": { + "computed_optional_required": "computed", + "description": "Unix timestamp representing the date and time of last modification of the namespace." + }, + "name": "modified" + }, + { + "bool": { + "computed_optional_required": "computed", + "description": "If `true`, messages published on channels within this namespace can be updated or deleted after publishing. See the \u003ca href=\"https://ably.com/docs/messages#update\"\u003emessage interactions\u003c/a\u003e documentation for more details." + }, + "name": "mutable_messages" + }, + { + "bool": { + "computed_optional_required": "computed", + "description": "If `true`, the last message published on a channel will be stored for 365 days. You can access the stored message only by using the channel rewind mechanism and attaching with rewind=1. Please note that for each message stored, an additional message is deducted from your monthly allocation." + }, + "name": "persist_last" + }, + { + "bool": { + "computed_optional_required": "computed", + "description": "If `true`, all messages on a channel will be stored for 24 hours. You can access stored messages via the History API. Please note that for each message stored, an additional message is deducted from your monthly allocation." + }, + "name": "persisted" + }, + { + "bool": { + "computed_optional_required": "computed", + "description": "If `true`, channels within this namespace are included in the channel registry, enabling channel enumeration via the \u003ca href=\"https://ably.com/docs/metadata-stats/metadata/subscribe#channel-lifecycle\"\u003eChannel Metadata API\u003c/a\u003e." + }, + "name": "populate_channel_registry" + }, + { + "bool": { + "computed_optional_required": "computed", + "description": "If `true`, publishing messages with a push payload in the extras field is permitted and can trigger the delivery of a native push notification to registered devices for the channel." + }, + "name": "push_enabled" + }, + { + "bool": { + "computed_optional_required": "computed", + "description": "If `true`, only clients that are connected using TLS will be permitted to subscribe to any channels within this namespace." + }, + "name": "tls_only" + } + ] + } + } + } + ] + } + }, + { + "name": "queues", + "schema": { + "attributes": [ + { + "name": "app_id", + "string": { + "computed_optional_required": "required", + "description": "The application ID." + } + }, + { + "name": "queues", + "set_nested": { + "computed_optional_required": "computed", + "nested_object": { + "attributes": [ + { + "name": "amqp", + "single_nested": { + "attributes": [ + { + "name": "queue_name", + "string": { + "computed_optional_required": "computed", + "description": "Name of the Ably queue." + } + }, + { + "name": "uri", + "string": { + "computed_optional_required": "computed", + "description": "URI for the AMQP queue interface." + } + } + ], + "computed_optional_required": "computed" + } + }, + { + "name": "app_id", + "string": { + "computed_optional_required": "computed", + "description": "The Ably application ID." + } + }, + { + "bool": { + "computed_optional_required": "computed", + "description": "A boolean that indicates whether this is a dead letter queue or not." + }, + "name": "deadletter" + }, + { + "name": "deadletter_id", + "string": { + "computed_optional_required": "computed", + "description": "The ID of the dead letter queue." + } + }, + { + "name": "id", + "string": { + "computed_optional_required": "computed", + "description": "The ID of the Ably queue" + } + }, + { + "int64": { + "computed_optional_required": "computed", + "description": "Message limit in number of messages." + }, + "name": "max_length" + }, + { + "name": "messages", + "single_nested": { + "attributes": [ + { + "int64": { + "computed_optional_required": "computed", + "description": "The number of ready messages in the queue." + }, + "name": "ready" + }, + { + "int64": { + "computed_optional_required": "computed", + "description": "The total number of messages in the queue." + }, + "name": "total" + }, + { + "int64": { + "computed_optional_required": "computed", + "description": "The number of unacknowledged messages in the queue." + }, + "name": "unacknowledged" + } + ], + "computed_optional_required": "computed", + "description": "Details of messages in the queue." + } + }, + { + "name": "name", + "string": { + "computed_optional_required": "computed", + "description": "The friendly name of the queue." + } + }, + { + "name": "region", + "string": { + "computed_optional_required": "computed", + "description": "The data center region for the queue." + } + }, + { + "name": "state", + "string": { + "computed_optional_required": "computed", + "description": "The current state of the queue." + } + }, + { + "name": "stats", + "single_nested": { + "attributes": [ + { + "name": "acknowledgement_rate", + "number": { + "computed_optional_required": "computed", + "description": "The rate at which messages are acknowledged. Rate is messages per minute." + } + }, + { + "name": "delivery_rate", + "number": { + "computed_optional_required": "computed", + "description": "The rate at which messages are delivered from the queue. Rate is messages per minute." + } + }, + { + "name": "publish_rate", + "number": { + "computed_optional_required": "computed", + "description": "The rate at which messages are published to the queue. Rate is messages per minute." + } + } + ], + "computed_optional_required": "computed" + } + }, + { + "name": "stomp", + "single_nested": { + "attributes": [ + { + "name": "destination", + "string": { + "computed_optional_required": "computed", + "description": "Destination queue." + } + }, + { + "name": "host", + "string": { + "computed_optional_required": "computed", + "description": "The host type for the queue." + } + }, + { + "name": "uri", + "string": { + "computed_optional_required": "computed", + "description": "URI for the STOMP queue interface." + } + } + ], + "computed_optional_required": "computed" + } + }, + { + "int64": { + "computed_optional_required": "computed", + "description": "TTL in minutes." + }, + "name": "ttl" + } + ] + } + } + } + ] + } + } + ], "provider": { "name": "ably" }, @@ -370,7 +1047,32 @@ "name": "name", "string": { "computed_optional_required": "required", - "description": "A friendly name for your queue." + "description": "A friendly name for your queue. May only contain letters, numbers, hyphens and underscores, up to 64 characters.", + "validators": [ + { + "custom": { + "imports": [ + { + "path": "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + } + ], + "schema_definition": "stringvalidator.LengthAtMost(64)" + } + }, + { + "custom": { + "imports": [ + { + "path": "regexp" + }, + { + "path": "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + } + ], + "schema_definition": "stringvalidator.RegexMatches(regexp.MustCompile(\"^[A-Za-z0-9_-]+$\"), \"\")" + } + } + ] } }, { diff --git a/control/rule_types_before_publish.go b/control/rule_types_before_publish.go index a3ab879..5c49212 100644 --- a/control/rule_types_before_publish.go +++ b/control/rule_types_before_publish.go @@ -63,6 +63,23 @@ type BeforePublishWebhookRulePatch struct { // Before-Publish AWS Lambda (ruleType: "aws/lambda/before-publish") // --------------------------------------------------------------------------- +// ChatMessageRuleSource is the source for before-publish rules. It is NOT the +// webhook/firehose RuleSource: the API validates it against its own schema +// (#/components/schemas/chat_message_rule_source), which carries only a type and +// rejects a channelFilter outright with "does not define properties: +// channelFilter". The only accepted type is "chat.message", and the API assigns +// this source itself when a rule is created without one. +// +// None of that is in the published spec, which $refs rule_source here; all of it +// was found against staging (2026-08-17). +// +// TODO(INF-7992): revisit once the spec documents chat_message_rule_source. The +// type stays either way, but its name should match whatever the spec settles on, +// and the comment below can shrink to a pointer at the docs. +type ChatMessageRuleSource struct { + Type string `json:"type"` +} + // BeforePublishAWSLambdaTarget is the target configuration for before-publish AWS Lambda rules. type BeforePublishAWSLambdaTarget struct { Region string `json:"region"` @@ -77,7 +94,7 @@ type BeforePublishAWSLambdaRulePost struct { BeforePublishConfig BeforePublishConfig `json:"beforePublishConfig"` InvocationMode string `json:"invocationMode"` ChatRoomFilter string `json:"chatRoomFilter,omitempty"` - Source *RuleSource `json:"source,omitempty"` + Source *ChatMessageRuleSource `json:"source,omitempty"` Target BeforePublishAWSLambdaTarget `json:"target"` } @@ -96,6 +113,6 @@ type BeforePublishAWSLambdaRulePatch struct { BeforePublishConfig *BeforePublishConfigPatch `json:"beforePublishConfig,omitempty"` InvocationMode string `json:"invocationMode,omitempty"` ChatRoomFilter string `json:"chatRoomFilter,omitempty"` - Source *RuleSource `json:"source,omitempty"` + Source *ChatMessageRuleSource `json:"source,omitempty"` Target *BeforePublishAWSLambdaTargetPatch `json:"target,omitempty"` } diff --git a/control/rule_types_before_publish_test.go b/control/rule_types_before_publish_test.go index 2a945d4..6dd990d 100644 --- a/control/rule_types_before_publish_test.go +++ b/control/rule_types_before_publish_test.go @@ -270,7 +270,7 @@ func TestCreateRule_BeforePublishAWSLambda_WithSource(t *testing.T) { FailedAction: "allow", TooManyRequestsAction: "allow", }, InvocationMode: "single", - Source: &RuleSource{ChannelFilter: "^my-channel", Type: "channel.message"}, + Source: &ChatMessageRuleSource{Type: "chat.message"}, Target: BeforePublishAWSLambdaTarget{ Region: "us-west-2", FunctionName: "handler", Authentication: AWSAuthentication{ @@ -288,8 +288,10 @@ func TestCreateRule_BeforePublishAWSLambda_WithSource(t *testing.T) { require.NoError(t, json.NewDecoder(r.Body).Decode(&raw)) source, ok := raw["source"].(map[string]interface{}) require.True(t, ok, "source should be present when set") - assert.Equal(t, "^my-channel", source["channelFilter"]) - assert.Equal(t, "channel.message", source["type"]) + // The chat source is type-only. A channelFilter is rejected by the API + // ("does not define properties: channelFilter"), so it must not be sent. + assert.Equal(t, "chat.message", source["type"]) + assert.NotContains(t, source, "channelFilter") writeJSON(w, http.StatusCreated, RuleResponse{ID: "rule021", RuleType: "aws/lambda/before-publish"}) }) @@ -316,7 +318,7 @@ func TestUpdateRule_BeforePublishAWSLambda_Success(t *testing.T) { BeforePublishConfig: &bpc, InvocationMode: "single", ChatRoomFilter: "lobby-*", - Source: &RuleSource{ChannelFilter: "^filtered", Type: "channel.message"}, + Source: &ChatMessageRuleSource{Type: "chat.message"}, Target: &BeforePublishAWSLambdaTargetPatch{ Region: ptr("us-east-1"), FunctionName: ptr("updated-fn"), Authentication: &AWSAuthenticationPatch{ @@ -341,7 +343,9 @@ func TestUpdateRule_BeforePublishAWSLambda_Success(t *testing.T) { require.NotNil(t, got.BeforePublishConfig.RetryTimeout) assert.Equal(t, 20, *got.BeforePublishConfig.RetryTimeout) require.NotNil(t, got.Source) - assert.Equal(t, "^filtered", got.Source.ChannelFilter) + // The chat source carries a type and nothing else: sending a channelFilter + // is rejected by the API (see ChatMessageRuleSource). + assert.Equal(t, "chat.message", got.Source.Type) require.NotNil(t, got.Target) require.NotNil(t, got.Target.Authentication) require.NotNil(t, got.Target.Authentication.AuthenticationMode) diff --git a/control/types.go b/control/types.go index 32ebc0c..a9167ac 100644 --- a/control/types.go +++ b/control/types.go @@ -527,11 +527,15 @@ type Me struct { Account *MeAccount `json:"account,omitempty"` } -// MeToken holds token information from GET /me. +// MeToken holds token information from GET /me. ExpiresAt and LastUsedAt are +// ISO 8601 timestamps and are nil when the token never expires, or has not been +// used since Ably began tracking that. type MeToken struct { ID string `json:"id"` Name string `json:"name"` Capabilities []string `json:"capabilities"` + ExpiresAt *string `json:"expires_at,omitempty"` + LastUsedAt *string `json:"last_used_at,omitempty"` } // MeUser holds user information from GET /me. diff --git a/docs/data-sources/api_key.md b/docs/data-sources/api_key.md new file mode 100644 index 0000000..2c759bd --- /dev/null +++ b/docs/data-sources/api_key.md @@ -0,0 +1,43 @@ +--- +page_title: "ably_api_key Data Source - terraform-provider-ably" +subcategory: "" +description: |- + The ably_api_key data source looks up a single Ably API key by id or name, including its secret. The Control API has no fetch-by-id endpoint for keys, so this lists the app's keys and matches locally. +--- + +# ably_api_key (Data Source) + +The `ably_api_key` data source looks up a single Ably API key by id or name, including its secret. The Control API has no fetch-by-id endpoint for keys, so this lists the app's keys and matches locally. + + +## Example Usage + +```terraform +# Look up an existing API key by name. The result includes the key secret, so +# treat anything derived from it as a credential. +data "ably_api_key" "publisher" { + app_id = data.ably_app.existing.id + name = "publisher" +} +``` + + +## Schema + +### Required + +- `app_id` (String) The Ably app the key belongs to. + +### Optional + +- `id` (String) The key ID. Set either this or name. +- `name` (String) The key name. Set either this or id. Names are not unique, so a name matching more than one key is an error. + +### Read-Only + +- `capabilities` (Map of List of String) The capabilities that this key has. More information on capabilities can be found in the Ably capabilities docs. +- `created` (Number) Unix timestamp representing the date and time of creation of the key. +- `key` (String, Sensitive) The complete API key including API secret. +- `modified` (Number) Unix timestamp representing the date and time of the last modification of the key. +- `revocable_tokens` (Boolean) If `true`, tokens issued by this key can be individually revoked. See the token revocation documentation for more details. +- `status` (Number) The status of the key. `0` is enabled, `1` is revoked. diff --git a/docs/data-sources/api_keys.md b/docs/data-sources/api_keys.md new file mode 100644 index 0000000..9b34bc3 --- /dev/null +++ b/docs/data-sources/api_keys.md @@ -0,0 +1,51 @@ +--- +page_title: "ably_api_keys Data Source - terraform-provider-ably" +subcategory: "" +description: |- + The ably_api_keys data source lists every API key in an Ably app, including keys Terraform does not manage. Each key includes its secret, so treat anything derived from this data source as a credential. Use ably_api_key to look up a single key by id or name. +--- + +# ably_api_keys (Data Source) + +The `ably_api_keys` data source lists every API key in an Ably app, including keys Terraform does not manage. Each key includes its secret, so treat anything derived from this data source as a credential. Use `ably_api_key` to look up a single key by id or name. + + +## Example Usage + +```terraform +# Every API key in an app. Each entry includes its secret, so treat anything +# derived from this data source as a credential. +data "ably_api_keys" "all" { + app_id = data.ably_app.existing.id +} + +output "key_names" { + value = [for key in data.ably_api_keys.all.keys : key.name] +} +``` + + +## Schema + +### Required + +- `app_id` (String) The application ID. + +### Read-Only + +- `keys` (Attributes Set) (see [below for nested schema](#nestedatt--keys)) + + +### Nested Schema for `keys` + +Read-Only: + +- `app_id` (String) The Ably application ID which this key is associated with. +- `capabilities` (Map of List of String) The capabilities that this key has. More information on capabilities can be found in the Ably capabilities docs. +- `created` (Number) Unix timestamp representing the date and time of creation of the key. +- `id` (String) The key ID. +- `key` (String, Sensitive) The complete API key including API secret. +- `modified` (Number) Unix timestamp representing the date and time of the last modification of the key. +- `name` (String) The name for your API key. This is a friendly name for your reference. +- `revocable_tokens` (Boolean) If `true`, tokens issued by this key can be individually revoked. See the token revocation documentation for more details. +- `status` (Number) The status of the key. `0` is enabled, `1` is revoked. diff --git a/docs/data-sources/app.md b/docs/data-sources/app.md new file mode 100644 index 0000000..d2f331d --- /dev/null +++ b/docs/data-sources/app.md @@ -0,0 +1,50 @@ +--- +page_title: "ably_app Data Source - terraform-provider-ably" +subcategory: "" +description: |- + The ably_app data source looks up a single Ably app by id or name, so you can reference an app this Terraform configuration does not manage. The Control API has no fetch-by-id endpoint for apps, so this lists the account's apps and matches locally. +--- + +# ably_app (Data Source) + +The `ably_app` data source looks up a single Ably app by id or name, so you can reference an app this Terraform configuration does not manage. The Control API has no fetch-by-id endpoint for apps, so this lists the account's apps and matches locally. + + +## Example Usage + +```terraform +# Look up an app this configuration does not manage, by name. +data "ably_app" "existing" { + name = "my-existing-app" +} + +# Or by ID, if you have it. +data "ably_app" "by_id" { + id = "abcdef" +} +``` + + +## Schema + +### Optional + +- `account_id` (String) The account to search. Defaults to the account the provider's token belongs to. +- `id` (String) The app ID. Set either this or name. +- `name` (String) The app name. Set either this or id. Names are not unique, so a name matching more than one app is an error. + +### Read-Only + +- `apns_auth_type` (String) The Apple Push Notification service authentication type. Use `certificate` for p12 certificate-based auth, or `token` for p8 key-based auth. +- `apns_certificate_configured` (Boolean) Whether a certificate-based APNs configuration is currently active. +- `apns_issuer_key` (String) The Team ID from the Apple Developer account. Used as the issuer when generating APNs authentication tokens. +- `apns_signing_key_configured` (Boolean) Whether a token-based (p8) APNs signing key is currently configured. +- `apns_signing_key_id` (String) The 10-character Key ID from the Apple Developer portal, associated with the `.p8` signing key. +- `apns_topic_header` (String) The app's bundle ID (for example, `com.example.myapp`). Used as the `apns-topic` header when sending push notifications. +- `apns_use_sandbox_endpoint` (Boolean) Use the Apple Push Notification service sandbox endpoint. +- `created` (Number) Unix timestamp representing the date and time of creation of the application. +- `fcm_project_id` (String) The unique identifier for the Firebase Cloud Messaging (FCM) project. This ID is used to specify the Firebase project when configuring FCM or other Firebase services. +- `fcm_service_account_configured` (Boolean) Whether Firebase Cloud Messaging (FCM) service account credentials are currently configured. +- `modified` (Number) Unix timestamp representing the date and time of last modification of the application. +- `status` (String) The status of the application. Can be `enabled` or `disabled`. Enabled means available to accept inbound connections and all services are available. +- `tls_only` (Boolean) Enforce TLS for all connections. This setting overrides any channel setting. diff --git a/docs/data-sources/apps.md b/docs/data-sources/apps.md new file mode 100644 index 0000000..16f5c92 --- /dev/null +++ b/docs/data-sources/apps.md @@ -0,0 +1,60 @@ +--- +page_title: "ably_apps Data Source - terraform-provider-ably" +subcategory: "" +description: |- + The ably_apps data source lists every Ably app in an account, including apps Terraform does not manage. Use ably_app to look up a single app by id or name. +--- + +# ably_apps (Data Source) + +The `ably_apps` data source lists every Ably app in an account, including apps Terraform does not manage. Use `ably_app` to look up a single app by id or name. + + +## Example Usage + +```terraform +# Every app in the account the provider's token belongs to. +data "ably_apps" "all" {} + +output "app_ids" { + value = [for app in data.ably_apps.all.apps : app.id] +} + +# A different account, if the token can see it. +data "ably_apps" "other_account" { + account_id = "abcdef" +} +``` + + +## Schema + +### Optional + +- `account_id` (String) The account to list apps for. Defaults to the account the provider's token belongs to. + +### Read-Only + +- `apps` (Attributes Set) (see [below for nested schema](#nestedatt--apps)) + + +### Nested Schema for `apps` + +Read-Only: + +- `account_id` (String) The ID of your Ably account. +- `apns_auth_type` (String) The Apple Push Notification service authentication type. Use `certificate` for p12 certificate-based auth, or `token` for p8 key-based auth. +- `apns_certificate_configured` (Boolean) Whether a certificate-based APNs configuration is currently active. +- `apns_issuer_key` (String) The Team ID from the Apple Developer account. Used as the issuer when generating APNs authentication tokens. +- `apns_signing_key_configured` (Boolean) Whether a token-based (p8) APNs signing key is currently configured. +- `apns_signing_key_id` (String) The 10-character Key ID from the Apple Developer portal, associated with the `.p8` signing key. +- `apns_topic_header` (String) The app's bundle ID (for example, `com.example.myapp`). Used as the `apns-topic` header when sending push notifications. +- `apns_use_sandbox_endpoint` (Boolean) Use the Apple Push Notification service sandbox endpoint. +- `created` (Number) Unix timestamp representing the date and time of creation of the application. +- `fcm_project_id` (String) The unique identifier for the Firebase Cloud Messaging (FCM) project. This ID is used to specify the Firebase project when configuring FCM or other Firebase services. +- `fcm_service_account_configured` (Boolean) Whether Firebase Cloud Messaging (FCM) service account credentials are currently configured. +- `id` (String) The application ID. +- `modified` (Number) Unix timestamp representing the date and time of last modification of the application. +- `name` (String) The application name. +- `status` (String) The status of the application. Can be `enabled` or `disabled`. Enabled means available to accept inbound connections and all services are available. +- `tls_only` (Boolean) Enforce TLS for all connections. This setting overrides any channel setting. diff --git a/docs/data-sources/me.md b/docs/data-sources/me.md new file mode 100644 index 0000000..997d451 --- /dev/null +++ b/docs/data-sources/me.md @@ -0,0 +1,61 @@ +--- +page_title: "ably_me Data Source - terraform-provider-ably" +subcategory: "" +description: |- + The ably_me data source describes the token the provider is configured with, and the account and user it belongs to. It is the way to reference your account ID without hardcoding it. +--- + +# ably_me (Data Source) + +The `ably_me` data source describes the token the provider is configured with, and the account and user it belongs to. It is the way to reference your account ID without hardcoding it. + + +## Example Usage + +```terraform +# Describes the token the provider is configured with, and the account it +# belongs to. Useful for referencing your account ID without hardcoding it. +data "ably_me" "current" {} + +output "account_id" { + value = data.ably_me.current.account.id +} +``` + + +## Schema + +### Read-Only + +- `account` (Attributes) (see [below for nested schema](#nestedatt--account)) +- `token` (Attributes) (see [below for nested schema](#nestedatt--token)) +- `user` (Attributes) (see [below for nested schema](#nestedatt--user)) + + +### Nested Schema for `account` + +Read-Only: + +- `id` (String) The account ID. +- `name` (String) The name of the account. + + + +### Nested Schema for `token` + +Read-Only: + +- `capabilities` (List of String) An array containing the access capabilities associated with the access token. +- `expires_at` (String) The date and time at which the access token expires, in ISO 8601 format. A value of `null` indicates the token does not expire. +- `id` (String) The token ID. +- `last_used_at` (String) The date and time at which the access token was last used to authenticate a Control API request, in ISO 8601 format. Updated at most once per 10-minute window. A value of `null` indicates the token has not been used since tracking began. +- `name` (String) The friendly name for the token. + + + +### Nested Schema for `user` + +Read-Only: + +- `email` (String) Email address of the user associated with the account. +- `id` (Number) The user ID associated with the account. diff --git a/docs/data-sources/namespace.md b/docs/data-sources/namespace.md new file mode 100644 index 0000000..4328c9a --- /dev/null +++ b/docs/data-sources/namespace.md @@ -0,0 +1,49 @@ +--- +page_title: "ably_namespace Data Source - terraform-provider-ably" +subcategory: "" +description: |- + The ably_namespace data source looks up a single Ably namespace (channel rule) by id, so you can read the settings of a namespace this Terraform configuration does not manage. The Control API has no fetch-by-id endpoint for namespaces, so this lists the app's namespaces and matches locally. +--- + +# ably_namespace (Data Source) + +The `ably_namespace` data source looks up a single Ably namespace (channel rule) by id, so you can read the settings of a namespace this Terraform configuration does not manage. The Control API has no fetch-by-id endpoint for namespaces, so this lists the app's namespaces and matches locally. + + +## Example Usage + +```terraform +# Read the settings of a namespace (channel rule) this configuration does not +# manage. A namespace's ID is its channel name prefix. +data "ably_namespace" "chat" { + app_id = data.ably_app.existing.id + id = "chat" +} +``` + + +## Schema + +### Required + +- `app_id` (String) The Ably app the namespace belongs to. +- `id` (String) The namespace ID, which is the channel name prefix (for example `chat`). + +### Read-Only + +- `authenticated` (Boolean, Deprecated) If `true`, clients will not be permitted to use (including to attach, publish, or subscribe) any channels within this namespace unless they are identified, that is, authenticated using a client ID. See the documentation for identified clients for more details. +- `batching_enabled` (Boolean) If `true`, channels within this namespace will start batching inbound messages instead of sending them out immediately to subscribers. +- `batching_interval` (Number) If `true`, the batching interval, configurable from 20ms to 1s, temporarily holds and aggregates incoming messages. These messages are then combined and delivered as a single batch once the interval elapses or a size limit is reached (before the interval elapses). +- `conflation_enabled` (Boolean) If `true`, enables conflation for channels within this namespace. Conflation will aggregate published messages for a set period of time and evaluate them against a conflation key. Only the most recent message that satisfies the key will be sent to subscribers at the end of the conflation interval. +- `conflation_interval` (Number) The interval in milliseconds over which messages are held and evaluated for conflation. +- `conflation_key` (String) The key used to determine which messages should be conflated. +- `created` (Number) Unix timestamp representing the date and time of creation of the namespace. +- `expose_timeserial` (Boolean) If `true`, messages received on a channel will contain a unique `timeserial` that can be referenced by later messages for use with message interactions. +- `identified` (Boolean) If `true`, clients are not permitted to use any channel in this namespace unless they are identified, that is, authenticated with a client ID. This is the canonical name for the flag the API also reports as `authenticated`. +- `modified` (Number) Unix timestamp representing the date and time of last modification of the namespace. +- `mutable_messages` (Boolean) If `true`, messages published on channels within this namespace can be updated or deleted after publishing. See the message interactions documentation for more details. +- `persist_last` (Boolean) If `true`, the last message published on a channel will be stored for 365 days. You can access the stored message only by using the channel rewind mechanism and attaching with rewind=1. Please note that for each message stored, an additional message is deducted from your monthly allocation. +- `persisted` (Boolean) If `true`, all messages on a channel will be stored for 24 hours. You can access stored messages via the History API. Please note that for each message stored, an additional message is deducted from your monthly allocation. +- `populate_channel_registry` (Boolean) If `true`, channels within this namespace are included in the channel registry, enabling channel enumeration via the Channel Metadata API. +- `push_enabled` (Boolean) If `true`, publishing messages with a push payload in the extras field is permitted and can trigger the delivery of a native push notification to registered devices for the channel. +- `tls_only` (Boolean) If `true`, only clients that are connected using TLS will be permitted to subscribe to any channels within this namespace. diff --git a/docs/data-sources/namespaces.md b/docs/data-sources/namespaces.md new file mode 100644 index 0000000..3390f61 --- /dev/null +++ b/docs/data-sources/namespaces.md @@ -0,0 +1,60 @@ +--- +page_title: "ably_namespaces Data Source - terraform-provider-ably" +subcategory: "" +description: |- + The ably_namespaces data source lists every namespace (channel rule) in an Ably app, including namespaces Terraform does not manage. Use ably_namespace to look up a single namespace by id. +--- + +# ably_namespaces (Data Source) + +The `ably_namespaces` data source lists every namespace (channel rule) in an Ably app, including namespaces Terraform does not manage. Use `ably_namespace` to look up a single namespace by id. + + +## Example Usage + +```terraform +# Every namespace (channel rule) in an app, including ones Terraform does not +# manage. +data "ably_namespaces" "all" { + app_id = data.ably_app.existing.id +} + +output "persisted_namespaces" { + value = [for ns in data.ably_namespaces.all.namespaces : ns.id if ns.persisted] +} +``` + + +## Schema + +### Required + +- `app_id` (String) The application ID. + +### Read-Only + +- `namespaces` (Attributes Set) (see [below for nested schema](#nestedatt--namespaces)) + + +### Nested Schema for `namespaces` + +Read-Only: + +- `app_id` (String) The ID of the application the namespace belongs to. +- `authenticated` (Boolean, Deprecated) If `true`, clients will not be permitted to use (including to attach, publish, or subscribe) any channels within this namespace unless they are identified, that is, authenticated using a client ID. See the documentation for identified clients for more details. +- `batching_enabled` (Boolean) If `true`, channels within this namespace will start batching inbound messages instead of sending them out immediately to subscribers. +- `batching_interval` (Number) If `true`, the batching interval, configurable from 20ms to 1s, temporarily holds and aggregates incoming messages. These messages are then combined and delivered as a single batch once the interval elapses or a size limit is reached (before the interval elapses). +- `conflation_enabled` (Boolean) If `true`, enables conflation for channels within this namespace. Conflation will aggregate published messages for a set period of time and evaluate them against a conflation key. Only the most recent message that satisfies the key will be sent to subscribers at the end of the conflation interval. +- `conflation_interval` (Number) The interval in milliseconds over which messages are held and evaluated for conflation. +- `conflation_key` (String) The key used to determine which messages should be conflated. +- `created` (Number) Unix timestamp representing the date and time of creation of the namespace. +- `expose_timeserial` (Boolean) If `true`, messages received on a channel will contain a unique `timeserial` that can be referenced by later messages for use with message interactions. +- `id` (String) The namespace or channel name that the channel rule will apply to. For example, if you specify `namespace` the namespace will be set to `namespace` and will match with channels `namespace:*` and `namespace`. +- `identified` (Boolean) If `true`, clients are not permitted to use any channel in this namespace unless they are identified, that is, authenticated with a client ID. This is the canonical name for the flag the API also reports as `authenticated`. +- `modified` (Number) Unix timestamp representing the date and time of last modification of the namespace. +- `mutable_messages` (Boolean) If `true`, messages published on channels within this namespace can be updated or deleted after publishing. See the message interactions documentation for more details. +- `persist_last` (Boolean) If `true`, the last message published on a channel will be stored for 365 days. You can access the stored message only by using the channel rewind mechanism and attaching with rewind=1. Please note that for each message stored, an additional message is deducted from your monthly allocation. +- `persisted` (Boolean) If `true`, all messages on a channel will be stored for 24 hours. You can access stored messages via the History API. Please note that for each message stored, an additional message is deducted from your monthly allocation. +- `populate_channel_registry` (Boolean) If `true`, channels within this namespace are included in the channel registry, enabling channel enumeration via the Channel Metadata API. +- `push_enabled` (Boolean) If `true`, publishing messages with a push payload in the extras field is permitted and can trigger the delivery of a native push notification to registered devices for the channel. +- `tls_only` (Boolean) If `true`, only clients that are connected using TLS will be permitted to subscribe to any channels within this namespace. diff --git a/docs/data-sources/queue.md b/docs/data-sources/queue.md new file mode 100644 index 0000000..924d593 --- /dev/null +++ b/docs/data-sources/queue.md @@ -0,0 +1,89 @@ +--- +page_title: "ably_queue Data Source - terraform-provider-ably" +subcategory: "" +description: |- + The ably_queue data source looks up a single Ably queue by id or name, with its AMQP and STOMP connection details. The Control API has no fetch-by-id endpoint for queues, so this lists the app's queues and matches locally. +--- + +# ably_queue (Data Source) + +The `ably_queue` data source looks up a single Ably queue by id or name, with its AMQP and STOMP connection details. The Control API has no fetch-by-id endpoint for queues, so this lists the app's queues and matches locally. + + +## Example Usage + +```terraform +# Look up a queue and use its AMQP connection details. +data "ably_queue" "events" { + app_id = data.ably_app.existing.id + name = "events" +} + +output "events_queue_amqp_uri" { + value = data.ably_queue.events.amqp.uri + sensitive = true +} +``` + + +## Schema + +### Required + +- `app_id` (String) The Ably app the queue belongs to. + +### Optional + +- `id` (String) The queue ID. Set either this or name. +- `name` (String) The queue name. Set either this or id. Names are not unique, so a name matching more than one queue is an error. + +### Read-Only + +- `amqp` (Attributes) (see [below for nested schema](#nestedatt--amqp)) +- `deadletter` (Boolean) A boolean that indicates whether this is a dead letter queue or not. +- `deadletter_id` (String) The ID of the dead letter queue. +- `max_length` (Number) Message limit in number of messages. +- `messages` (Attributes) Details of messages in the queue. (see [below for nested schema](#nestedatt--messages)) +- `region` (String) The data center region for the queue. +- `state` (String) The current state of the queue. +- `stats` (Attributes) (see [below for nested schema](#nestedatt--stats)) +- `stomp` (Attributes) (see [below for nested schema](#nestedatt--stomp)) +- `ttl` (Number) TTL in minutes. + + +### Nested Schema for `amqp` + +Read-Only: + +- `queue_name` (String) Name of the Ably queue. +- `uri` (String) URI for the AMQP queue interface. + + + +### Nested Schema for `messages` + +Read-Only: + +- `ready` (Number) The number of ready messages in the queue. +- `total` (Number) The total number of messages in the queue. +- `unacknowledged` (Number) The number of unacknowledged messages in the queue. + + + +### Nested Schema for `stats` + +Read-Only: + +- `acknowledgement_rate` (Number) The rate at which messages are acknowledged. Rate is messages per minute. +- `delivery_rate` (Number) The rate at which messages are delivered from the queue. Rate is messages per minute. +- `publish_rate` (Number) The rate at which messages are published to the queue. Rate is messages per minute. + + + +### Nested Schema for `stomp` + +Read-Only: + +- `destination` (String) Destination queue. +- `host` (String) The host type for the queue. +- `uri` (String) URI for the STOMP queue interface. diff --git a/docs/data-sources/queues.md b/docs/data-sources/queues.md new file mode 100644 index 0000000..33fc8a1 --- /dev/null +++ b/docs/data-sources/queues.md @@ -0,0 +1,92 @@ +--- +page_title: "ably_queues Data Source - terraform-provider-ably" +subcategory: "" +description: |- + The ably_queues data source lists every queue in an Ably app, including queues Terraform does not manage, with their connection details and live message counts. Use ably_queue to look up a single queue by id or name. +--- + +# ably_queues (Data Source) + +The `ably_queues` data source lists every queue in an Ably app, including queues Terraform does not manage, with their connection details and live message counts. Use `ably_queue` to look up a single queue by id or name. + + +## Example Usage + +```terraform +# Every queue in an app, with connection details and live message counts. +data "ably_queues" "all" { + app_id = data.ably_app.existing.id +} + +output "queue_backlogs" { + value = { for queue in data.ably_queues.all.queues : queue.name => queue.messages.total } +} +``` + + +## Schema + +### Required + +- `app_id` (String) The application ID. + +### Read-Only + +- `queues` (Attributes Set) (see [below for nested schema](#nestedatt--queues)) + + +### Nested Schema for `queues` + +Read-Only: + +- `amqp` (Attributes) (see [below for nested schema](#nestedatt--queues--amqp)) +- `app_id` (String) The Ably application ID. +- `deadletter` (Boolean) A boolean that indicates whether this is a dead letter queue or not. +- `deadletter_id` (String) The ID of the dead letter queue. +- `id` (String) The ID of the Ably queue +- `max_length` (Number) Message limit in number of messages. +- `messages` (Attributes) Details of messages in the queue. (see [below for nested schema](#nestedatt--queues--messages)) +- `name` (String) The friendly name of the queue. +- `region` (String) The data center region for the queue. +- `state` (String) The current state of the queue. +- `stats` (Attributes) (see [below for nested schema](#nestedatt--queues--stats)) +- `stomp` (Attributes) (see [below for nested schema](#nestedatt--queues--stomp)) +- `ttl` (Number) TTL in minutes. + + +### Nested Schema for `queues.amqp` + +Read-Only: + +- `queue_name` (String) Name of the Ably queue. +- `uri` (String) URI for the AMQP queue interface. + + + +### Nested Schema for `queues.messages` + +Read-Only: + +- `ready` (Number) The number of ready messages in the queue. +- `total` (Number) The total number of messages in the queue. +- `unacknowledged` (Number) The number of unacknowledged messages in the queue. + + + +### Nested Schema for `queues.stats` + +Read-Only: + +- `acknowledgement_rate` (Number) The rate at which messages are acknowledged. Rate is messages per minute. +- `delivery_rate` (Number) The rate at which messages are delivered from the queue. Rate is messages per minute. +- `publish_rate` (Number) The rate at which messages are published to the queue. Rate is messages per minute. + + + +### Nested Schema for `queues.stomp` + +Read-Only: + +- `destination` (String) Destination queue. +- `host` (String) The host type for the queue. +- `uri` (String) URI for the STOMP queue interface. diff --git a/docs/resources/rule_azure_moderation.md b/docs/resources/rule_azure_moderation.md new file mode 100644 index 0000000..c92751a --- /dev/null +++ b/docs/resources/rule_azure_moderation.md @@ -0,0 +1,78 @@ +--- +page_title: "ably_rule_azure_moderation Resource - terraform-provider-ably" +subcategory: "" +description: |- + The ably_rule_azure_moderation resource allows you to create and manage an Ably integration rule for Azure AI Content Safety text moderation. This rule moderates messages before they are published. Read more at https://ably.com/docs/chat/moderation +--- + +# ably_rule_azure_moderation (Resource) + +The `ably_rule_azure_moderation` resource allows you to create and manage an Ably integration rule for Azure AI Content Safety text moderation. This rule moderates messages before they are published. Read more at https://ably.com/docs/chat/moderation + + +## Example Usage + +```terraform +resource "ably_rule_azure_moderation" "rule0" { + app_id = ably_app.app0.id + status = "enabled" + invocation_mode = "BEFORE_PUBLISH" + chat_room_filter = "/room-.*/" + before_publish_config = { + retry_timeout = 5000 + max_retries = 3 + failed_action = "PUBLISH" + too_many_requests_action = "RETRY" + } + target = { + api_key = "my-azure-api-key" + endpoint = "https://my-resource.cognitiveservices.azure.com" + thresholds = { + Hate = 2 + Violence = 3 + } + } +} +``` + + +## Schema + +### Required + +- `app_id` (String) The Ably application ID. +- `before_publish_config` (Attributes) Configuration for before-publish behavior, including retry logic and failure handling. (see [below for nested schema](#nestedatt--before_publish_config)) +- `target` (Attributes) The target for the rule, specifying the Azure Content Safety configuration. (see [below for nested schema](#nestedatt--target)) + +### Optional + +- `chat_room_filter` (String) A regular expression that filters messages based on the chat room ID. Only messages matching this pattern will trigger the rule. +- `invocation_mode` (String) The invocation mode for this rule. Before-publish rules are invoked before a message is published. +- `status` (String) The status of the rule. Rules can be enabled or disabled. + +### Read-Only + +- `id` (String) The rule ID. + + +### Nested Schema for `before_publish_config` + +Required: + +- `failed_action` (String) The action to take if the rule invocation fails. `REJECT` prevents the message from being published, `PUBLISH` allows it through. +- `max_retries` (Number) The maximum number of retry attempts. +- `retry_timeout` (Number) The timeout in milliseconds for retrying the rule invocation. +- `too_many_requests_action` (String) The action to take if the rule invocation returns a rate limit response. `RETRY` will attempt the request again, `FAIL` will invoke the `failedAction`. + + + +### Nested Schema for `target` + +Required: + +- `api_key` (String, Sensitive) The Azure Content Safety API key for authenticating with the moderation service. +- `endpoint` (String) The Azure Content Safety endpoint URL. + +Optional: + +- `thresholds` (Map of Number) A map of moderation categories to threshold levels (0-7). Messages scoring above the threshold for any category will be rejected. diff --git a/docs/resources/rule_before_publish_lambda.md b/docs/resources/rule_before_publish_lambda.md new file mode 100644 index 0000000..c251b78 --- /dev/null +++ b/docs/resources/rule_before_publish_lambda.md @@ -0,0 +1,103 @@ +--- +page_title: "ably_rule_before_publish_lambda Resource - terraform-provider-ably" +subcategory: "" +description: |- + The ably_rule_before_publish_lambda resource allows you to create and manage an Ably integration rule that invokes an AWS Lambda function before a message is published, so the function can allow, reject or amend it. Read more at https://ably.com/docs/chat/moderation +--- + +# ably_rule_before_publish_lambda (Resource) + +The `ably_rule_before_publish_lambda` resource allows you to create and manage an Ably integration rule that invokes an AWS Lambda function before a message is published, so the function can allow, reject or amend it. Read more at https://ably.com/docs/chat/moderation + + +## Example Usage + +```terraform +resource "ably_rule_before_publish_lambda" "rule0" { + app_id = ably_app.app0.id + status = "enabled" + invocation_mode = "BEFORE_PUBLISH" + chat_room_filter = "/room-.*/" + before_publish_config = { + retry_timeout = 5000 + max_retries = 3 + failed_action = "PUBLISH" + too_many_requests_action = "RETRY" + } + # Optional: the Control API assigns a default source if you leave this out. + source = { + type = "chat.message" + } + target = { + region = "us-west-1" + function_name = "my-moderation-function" + authentication = { + authentication_mode = "credentials" + access_key_id = "AKIAIOSFODNN7EXAMPLE" + secret_access_key = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY" + } + } +} +``` + + +## Schema + +### Required + +- `app_id` (String) The Ably application ID. +- `before_publish_config` (Attributes) Configuration for before-publish behavior, including retry logic and failure handling. (see [below for nested schema](#nestedatt--before_publish_config)) +- `target` (Attributes) The target for the rule, specifying the AWS Lambda function to invoke. (see [below for nested schema](#nestedatt--target)) + +### Optional + +- `chat_room_filter` (String) A regular expression that filters messages based on the chat room ID. Only messages matching this pattern will trigger the rule. +- `invocation_mode` (String) The invocation mode for this rule. Before-publish rules are invoked before a message is published. +- `source` (Attributes) The source of messages this rule applies to. Optional: the Control API assigns a default source when it is omitted. (see [below for nested schema](#nestedatt--source)) +- `status` (String) The status of the rule. Rules can be enabled or disabled. + +### Read-Only + +- `id` (String) The rule ID. + + +### Nested Schema for `before_publish_config` + +Required: + +- `failed_action` (String) The action to take if the rule invocation fails. `REJECT` prevents the message from being published, `PUBLISH` allows it through. +- `max_retries` (Number) The maximum number of retry attempts. +- `retry_timeout` (Number) The timeout in milliseconds for retrying the rule invocation. +- `too_many_requests_action` (String) The action to take if the rule invocation returns a rate limit response. `RETRY` will attempt the request again, `FAIL` will invoke the `failedAction`. + + + +### Nested Schema for `target` + +Required: + +- `authentication` (Attributes) (see [below for nested schema](#nestedatt--target--authentication)) +- `function_name` (String) The name of your AWS Lambda function. +- `region` (String) The AWS region in which your Lambda function is hosted. See the AWS documentation for more detail. + + +### Nested Schema for `target.authentication` + +Required: + +- `authentication_mode` (String) Authentication method is using AWS credentials (AWS key ID and secret key). + +Optional: + +- `access_key_id` (String, Sensitive) The AWS key ID for the AWS IAM user. See the Ably AWS authentication docs for details. +- `assume_role_arn` (String) If you are using the "ARN of an assumable role" authentication method, this is your Assume Role ARN. See the Ably AWS authentication docs for details. +- `secret_access_key` (String, Sensitive) The AWS secret key for the AWS IAM user. See the Ably AWS authentication docs for details. + + + + +### Nested Schema for `source` + +Required: + +- `type` (String) The source type. Before-publish rules act on chat messages, so `chat.message` is the only supported value. diff --git a/docs/resources/rule_before_publish_webhook.md b/docs/resources/rule_before_publish_webhook.md new file mode 100644 index 0000000..2753095 --- /dev/null +++ b/docs/resources/rule_before_publish_webhook.md @@ -0,0 +1,86 @@ +--- +page_title: "ably_rule_before_publish_webhook Resource - terraform-provider-ably" +subcategory: "" +description: |- + The ably_rule_before_publish_webhook resource allows you to create and manage an Ably integration rule that calls your own HTTP endpoint before a message is published, so the endpoint can allow, reject or amend it. Read more at https://ably.com/docs/chat/moderation +--- + +# ably_rule_before_publish_webhook (Resource) + +The `ably_rule_before_publish_webhook` resource allows you to create and manage an Ably integration rule that calls your own HTTP endpoint before a message is published, so the endpoint can allow, reject or amend it. Read more at https://ably.com/docs/chat/moderation + + +## Example Usage + +```terraform +resource "ably_rule_before_publish_webhook" "rule0" { + app_id = ably_app.app0.id + status = "enabled" + invocation_mode = "BEFORE_PUBLISH" + chat_room_filter = "/room-.*/" + before_publish_config = { + retry_timeout = 5000 + max_retries = 3 + failed_action = "PUBLISH" + too_many_requests_action = "RETRY" + } + target = { + url = "https://example.com/moderate" + headers = [ + { + name = "X-Custom-Header" + value = "custom-header-value" + }, + ] + } +} +``` + + +## Schema + +### Required + +- `app_id` (String) The Ably application ID. +- `before_publish_config` (Attributes) Configuration for before-publish behavior, including retry logic and failure handling. (see [below for nested schema](#nestedatt--before_publish_config)) +- `target` (Attributes) The target for the rule, specifying the webhook endpoint to call. (see [below for nested schema](#nestedatt--target)) + +### Optional + +- `chat_room_filter` (String) A regular expression that filters messages based on the chat room ID. Only messages matching this pattern will trigger the rule. +- `invocation_mode` (String) The invocation mode for this rule. Before-publish rules are invoked before a message is published. +- `status` (String) The status of the rule. Rules can be enabled or disabled. + +### Read-Only + +- `id` (String) The rule ID. + + +### Nested Schema for `before_publish_config` + +Required: + +- `failed_action` (String) The action to take if the rule invocation fails. `REJECT` prevents the message from being published, `PUBLISH` allows it through. +- `max_retries` (Number) The maximum number of retry attempts. +- `retry_timeout` (Number) The timeout in milliseconds for retrying the rule invocation. +- `too_many_requests_action` (String) The action to take if the rule invocation returns a rate limit response. `RETRY` will attempt the request again, `FAIL` will invoke the `failedAction`. + + + +### Nested Schema for `target` + +Required: + +- `url` (String) The webhook URL that Ably will POST events to before publishing. + +Optional: + +- `headers` (Attributes List) (see [below for nested schema](#nestedatt--target--headers)) + + +### Nested Schema for `target.headers` + +Optional: + +- `name` (String) The name of the header. +- `value` (String, Sensitive) The value of the header. diff --git a/docs/resources/rule_hive_dashboard.md b/docs/resources/rule_hive_dashboard.md new file mode 100644 index 0000000..77efa85 --- /dev/null +++ b/docs/resources/rule_hive_dashboard.md @@ -0,0 +1,55 @@ +--- +page_title: "ably_rule_hive_dashboard Resource - terraform-provider-ably" +subcategory: "" +description: |- + The ably_rule_hive_dashboard resource allows you to create and manage an Ably integration rule for Hive AI moderation with the Hive dashboard. Unlike the other moderation rules this one runs after a message is published (invocation_mode is AFTER_PUBLISH), so it takes no before_publish_config. Read more at https://ably.com/docs/chat/moderation +--- + +# ably_rule_hive_dashboard (Resource) + +The `ably_rule_hive_dashboard` resource allows you to create and manage an Ably integration rule for Hive AI moderation with the Hive dashboard. Unlike the other moderation rules this one runs *after* a message is published (`invocation_mode` is `AFTER_PUBLISH`), so it takes no `before_publish_config`. Read more at https://ably.com/docs/chat/moderation + + +## Example Usage + +```terraform +resource "ably_rule_hive_dashboard" "rule0" { + app_id = ably_app.app0.id + status = "enabled" + invocation_mode = "AFTER_PUBLISH" + chat_room_filter = "/room-.*/" + target = { + api_key = "my-hive-api-key" + check_watch_lists = true + } +} +``` + + +## Schema + +### Required + +- `app_id` (String) The Ably application ID. +- `target` (Attributes) The target for the rule, specifying the Hive dashboard configuration. (see [below for nested schema](#nestedatt--target)) + +### Optional + +- `chat_room_filter` (String) A regular expression that filters messages based on the chat room ID. Only messages matching this pattern will trigger the rule. +- `invocation_mode` (String) The invocation mode for this rule. After-publish rules are invoked after a message is published. +- `status` (String) The status of the rule. Rules can be enabled or disabled. + +### Read-Only + +- `id` (String) The rule ID. + + +### Nested Schema for `target` + +Required: + +- `api_key` (String, Sensitive) The Hive API key for authenticating with the moderation dashboard. + +Optional: + +- `check_watch_lists` (Boolean) If `true`, messages will also be checked against Hive watch lists. diff --git a/docs/resources/rule_hive_text.md b/docs/resources/rule_hive_text.md new file mode 100644 index 0000000..55c7f45 --- /dev/null +++ b/docs/resources/rule_hive_text.md @@ -0,0 +1,77 @@ +--- +page_title: "ably_rule_hive_text Resource - terraform-provider-ably" +subcategory: "" +description: |- + The ably_rule_hive_text resource allows you to create and manage an Ably integration rule for Hive AI text moderation, using the model only with no dashboard. This rule moderates messages before they are published. Read more at https://ably.com/docs/chat/moderation +--- + +# ably_rule_hive_text (Resource) + +The `ably_rule_hive_text` resource allows you to create and manage an Ably integration rule for Hive AI text moderation, using the model only with no dashboard. This rule moderates messages before they are published. Read more at https://ably.com/docs/chat/moderation + + +## Example Usage + +```terraform +resource "ably_rule_hive_text" "rule0" { + app_id = ably_app.app0.id + status = "enabled" + invocation_mode = "BEFORE_PUBLISH" + chat_room_filter = "/room-.*/" + before_publish_config = { + retry_timeout = 5000 + max_retries = 3 + failed_action = "PUBLISH" + too_many_requests_action = "RETRY" + } + target = { + api_key = "my-hive-api-key" + thresholds = { + bullying = 2 + profanity = 3 + } + } +} +``` + + +## Schema + +### Required + +- `app_id` (String) The Ably application ID. +- `before_publish_config` (Attributes) Configuration for before-publish behavior, including retry logic and failure handling. (see [below for nested schema](#nestedatt--before_publish_config)) +- `target` (Attributes) The target for the rule, specifying the Hive AI moderation configuration. (see [below for nested schema](#nestedatt--target)) + +### Optional + +- `chat_room_filter` (String) A regular expression that filters messages based on the chat room ID. Only messages matching this pattern will trigger the rule. +- `invocation_mode` (String) The invocation mode for this rule. Before-publish rules are invoked before a message is published. +- `status` (String) The status of the rule. Rules can be enabled or disabled. + +### Read-Only + +- `id` (String) The rule ID. + + +### Nested Schema for `before_publish_config` + +Required: + +- `failed_action` (String) The action to take if the rule invocation fails. `REJECT` prevents the message from being published, `PUBLISH` allows it through. +- `max_retries` (Number) The maximum number of retry attempts. +- `retry_timeout` (Number) The timeout in milliseconds for retrying the rule invocation. +- `too_many_requests_action` (String) The action to take if the rule invocation returns a rate limit response. `RETRY` will attempt the request again, `FAIL` will invoke the `failedAction`. + + + +### Nested Schema for `target` + +Required: + +- `api_key` (String, Sensitive) The Hive API key for authenticating with the moderation service. + +Optional: + +- `model_url` (String) The URL of the Hive text classification model to use. +- `thresholds` (Map of Number) A map of moderation categories to threshold levels (1-3). Messages scoring above the threshold for any category will be rejected. diff --git a/docs/resources/rule_tisane.md b/docs/resources/rule_tisane.md new file mode 100644 index 0000000..532e022 --- /dev/null +++ b/docs/resources/rule_tisane.md @@ -0,0 +1,79 @@ +--- +page_title: "ably_rule_tisane Resource - terraform-provider-ably" +subcategory: "" +description: |- + The ably_rule_tisane resource allows you to create and manage an Ably integration rule for Tisane text moderation. This rule moderates messages before they are published. Read more at https://ably.com/docs/chat/moderation +--- + +# ably_rule_tisane (Resource) + +The `ably_rule_tisane` resource allows you to create and manage an Ably integration rule for Tisane text moderation. This rule moderates messages before they are published. Read more at https://ably.com/docs/chat/moderation + + +## Example Usage + +```terraform +resource "ably_rule_tisane" "rule0" { + app_id = ably_app.app0.id + status = "enabled" + invocation_mode = "BEFORE_PUBLISH" + chat_room_filter = "/room-.*/" + before_publish_config = { + retry_timeout = 5000 + max_retries = 3 + failed_action = "PUBLISH" + too_many_requests_action = "RETRY" + } + target = { + api_key = "my-tisane-api-key" + default_language = "en" + thresholds = { + abuse = 2 + profanity = 3 + } + } +} +``` + + +## Schema + +### Required + +- `app_id` (String) The Ably application ID. +- `before_publish_config` (Attributes) Configuration for before-publish behavior, including retry logic and failure handling. (see [below for nested schema](#nestedatt--before_publish_config)) +- `target` (Attributes) The target for the rule, specifying the Tisane moderation configuration. (see [below for nested schema](#nestedatt--target)) + +### Optional + +- `chat_room_filter` (String) A regular expression that filters messages based on the chat room ID. Only messages matching this pattern will trigger the rule. +- `invocation_mode` (String) The invocation mode for this rule. Before-publish rules are invoked before a message is published. +- `status` (String) The status of the rule. Rules can be enabled or disabled. + +### Read-Only + +- `id` (String) The rule ID. + + +### Nested Schema for `before_publish_config` + +Required: + +- `failed_action` (String) The action to take if the rule invocation fails. `REJECT` prevents the message from being published, `PUBLISH` allows it through. +- `max_retries` (Number) The maximum number of retry attempts. +- `retry_timeout` (Number) The timeout in milliseconds for retrying the rule invocation. +- `too_many_requests_action` (String) The action to take if the rule invocation returns a rate limit response. `RETRY` will attempt the request again, `FAIL` will invoke the `failedAction`. + + + +### Nested Schema for `target` + +Required: + +- `api_key` (String, Sensitive) The Tisane API key for authenticating with the moderation service. +- `default_language` (String) The default language for text moderation analysis. + +Optional: + +- `model_url` (String) The URL of the Tisane model to use for text analysis. +- `thresholds` (Map of Number) A map of moderation categories to threshold levels (0-3). Messages scoring above the threshold for any category will be rejected. diff --git a/examples/data-sources/api_key.tf b/examples/data-sources/api_key.tf new file mode 100644 index 0000000..1ccdb6a --- /dev/null +++ b/examples/data-sources/api_key.tf @@ -0,0 +1,6 @@ +# Look up an existing API key by name. The result includes the key secret, so +# treat anything derived from it as a credential. +data "ably_api_key" "publisher" { + app_id = data.ably_app.existing.id + name = "publisher" +} diff --git a/examples/data-sources/api_keys.tf b/examples/data-sources/api_keys.tf new file mode 100644 index 0000000..f991c52 --- /dev/null +++ b/examples/data-sources/api_keys.tf @@ -0,0 +1,9 @@ +# Every API key in an app. Each entry includes its secret, so treat anything +# derived from this data source as a credential. +data "ably_api_keys" "all" { + app_id = data.ably_app.existing.id +} + +output "key_names" { + value = [for key in data.ably_api_keys.all.keys : key.name] +} diff --git a/examples/data-sources/app.tf b/examples/data-sources/app.tf new file mode 100644 index 0000000..3a6b668 --- /dev/null +++ b/examples/data-sources/app.tf @@ -0,0 +1,9 @@ +# Look up an app this configuration does not manage, by name. +data "ably_app" "existing" { + name = "my-existing-app" +} + +# Or by ID, if you have it. +data "ably_app" "by_id" { + id = "abcdef" +} diff --git a/examples/data-sources/apps.tf b/examples/data-sources/apps.tf new file mode 100644 index 0000000..3625921 --- /dev/null +++ b/examples/data-sources/apps.tf @@ -0,0 +1,11 @@ +# Every app in the account the provider's token belongs to. +data "ably_apps" "all" {} + +output "app_ids" { + value = [for app in data.ably_apps.all.apps : app.id] +} + +# A different account, if the token can see it. +data "ably_apps" "other_account" { + account_id = "abcdef" +} diff --git a/examples/data-sources/me.tf b/examples/data-sources/me.tf new file mode 100644 index 0000000..67813f7 --- /dev/null +++ b/examples/data-sources/me.tf @@ -0,0 +1,7 @@ +# Describes the token the provider is configured with, and the account it +# belongs to. Useful for referencing your account ID without hardcoding it. +data "ably_me" "current" {} + +output "account_id" { + value = data.ably_me.current.account.id +} diff --git a/examples/data-sources/namespace.tf b/examples/data-sources/namespace.tf new file mode 100644 index 0000000..0b0d9e6 --- /dev/null +++ b/examples/data-sources/namespace.tf @@ -0,0 +1,6 @@ +# Read the settings of a namespace (channel rule) this configuration does not +# manage. A namespace's ID is its channel name prefix. +data "ably_namespace" "chat" { + app_id = data.ably_app.existing.id + id = "chat" +} diff --git a/examples/data-sources/namespaces.tf b/examples/data-sources/namespaces.tf new file mode 100644 index 0000000..9b0d0cd --- /dev/null +++ b/examples/data-sources/namespaces.tf @@ -0,0 +1,9 @@ +# Every namespace (channel rule) in an app, including ones Terraform does not +# manage. +data "ably_namespaces" "all" { + app_id = data.ably_app.existing.id +} + +output "persisted_namespaces" { + value = [for ns in data.ably_namespaces.all.namespaces : ns.id if ns.persisted] +} diff --git a/examples/data-sources/queue.tf b/examples/data-sources/queue.tf new file mode 100644 index 0000000..0c603df --- /dev/null +++ b/examples/data-sources/queue.tf @@ -0,0 +1,10 @@ +# Look up a queue and use its AMQP connection details. +data "ably_queue" "events" { + app_id = data.ably_app.existing.id + name = "events" +} + +output "events_queue_amqp_uri" { + value = data.ably_queue.events.amqp.uri + sensitive = true +} diff --git a/examples/data-sources/queues.tf b/examples/data-sources/queues.tf new file mode 100644 index 0000000..3ff7975 --- /dev/null +++ b/examples/data-sources/queues.tf @@ -0,0 +1,8 @@ +# Every queue in an app, with connection details and live message counts. +data "ably_queues" "all" { + app_id = data.ably_app.existing.id +} + +output "queue_backlogs" { + value = { for queue in data.ably_queues.all.queues : queue.name => queue.messages.total } +} diff --git a/examples/resources/rule_azure_moderation.tf b/examples/resources/rule_azure_moderation.tf new file mode 100644 index 0000000..12d009a --- /dev/null +++ b/examples/resources/rule_azure_moderation.tf @@ -0,0 +1,20 @@ +resource "ably_rule_azure_moderation" "rule0" { + app_id = ably_app.app0.id + status = "enabled" + invocation_mode = "BEFORE_PUBLISH" + chat_room_filter = "/room-.*/" + before_publish_config = { + retry_timeout = 5000 + max_retries = 3 + failed_action = "PUBLISH" + too_many_requests_action = "RETRY" + } + target = { + api_key = "my-azure-api-key" + endpoint = "https://my-resource.cognitiveservices.azure.com" + thresholds = { + Hate = 2 + Violence = 3 + } + } +} diff --git a/examples/resources/rule_before_publish_lambda.tf b/examples/resources/rule_before_publish_lambda.tf new file mode 100644 index 0000000..4f332a0 --- /dev/null +++ b/examples/resources/rule_before_publish_lambda.tf @@ -0,0 +1,25 @@ +resource "ably_rule_before_publish_lambda" "rule0" { + app_id = ably_app.app0.id + status = "enabled" + invocation_mode = "BEFORE_PUBLISH" + chat_room_filter = "/room-.*/" + before_publish_config = { + retry_timeout = 5000 + max_retries = 3 + failed_action = "PUBLISH" + too_many_requests_action = "RETRY" + } + # Optional: the Control API assigns a default source if you leave this out. + source = { + type = "chat.message" + } + target = { + region = "us-west-1" + function_name = "my-moderation-function" + authentication = { + authentication_mode = "credentials" + access_key_id = "AKIAIOSFODNN7EXAMPLE" + secret_access_key = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY" + } + } +} diff --git a/examples/resources/rule_before_publish_webhook.tf b/examples/resources/rule_before_publish_webhook.tf new file mode 100644 index 0000000..c8252c7 --- /dev/null +++ b/examples/resources/rule_before_publish_webhook.tf @@ -0,0 +1,21 @@ +resource "ably_rule_before_publish_webhook" "rule0" { + app_id = ably_app.app0.id + status = "enabled" + invocation_mode = "BEFORE_PUBLISH" + chat_room_filter = "/room-.*/" + before_publish_config = { + retry_timeout = 5000 + max_retries = 3 + failed_action = "PUBLISH" + too_many_requests_action = "RETRY" + } + target = { + url = "https://example.com/moderate" + headers = [ + { + name = "X-Custom-Header" + value = "custom-header-value" + }, + ] + } +} diff --git a/examples/resources/rule_hive_dashboard.tf b/examples/resources/rule_hive_dashboard.tf new file mode 100644 index 0000000..a615bd0 --- /dev/null +++ b/examples/resources/rule_hive_dashboard.tf @@ -0,0 +1,10 @@ +resource "ably_rule_hive_dashboard" "rule0" { + app_id = ably_app.app0.id + status = "enabled" + invocation_mode = "AFTER_PUBLISH" + chat_room_filter = "/room-.*/" + target = { + api_key = "my-hive-api-key" + check_watch_lists = true + } +} diff --git a/examples/resources/rule_hive_text.tf b/examples/resources/rule_hive_text.tf new file mode 100644 index 0000000..626ecf1 --- /dev/null +++ b/examples/resources/rule_hive_text.tf @@ -0,0 +1,19 @@ +resource "ably_rule_hive_text" "rule0" { + app_id = ably_app.app0.id + status = "enabled" + invocation_mode = "BEFORE_PUBLISH" + chat_room_filter = "/room-.*/" + before_publish_config = { + retry_timeout = 5000 + max_retries = 3 + failed_action = "PUBLISH" + too_many_requests_action = "RETRY" + } + target = { + api_key = "my-hive-api-key" + thresholds = { + bullying = 2 + profanity = 3 + } + } +} diff --git a/examples/resources/rule_tisane.tf b/examples/resources/rule_tisane.tf new file mode 100644 index 0000000..278db6b --- /dev/null +++ b/examples/resources/rule_tisane.tf @@ -0,0 +1,20 @@ +resource "ably_rule_tisane" "rule0" { + app_id = ably_app.app0.id + status = "enabled" + invocation_mode = "BEFORE_PUBLISH" + chat_room_filter = "/room-.*/" + before_publish_config = { + retry_timeout = 5000 + max_retries = 3 + failed_action = "PUBLISH" + too_many_requests_action = "RETRY" + } + target = { + api_key = "my-tisane-api-key" + default_language = "en" + thresholds = { + abuse = 2 + profanity = 3 + } + } +} diff --git a/internal/provider/before_publish_rules.go b/internal/provider/before_publish_rules.go new file mode 100644 index 0000000..347761b --- /dev/null +++ b/internal/provider/before_publish_rules.go @@ -0,0 +1,304 @@ +// Package provider implements the Ably provider for Terraform +package provider + +import ( + "context" + "fmt" + + "github.com/ably/terraform-provider-ably/control" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +// This file holds the shared plumbing for the moderation and before-publish rule +// families. Unlike webhook/firehose rules (see rules.go, AblyRule and +// CreateRule[T]) these rules have NO `source`* and NO `request_mode`; they carry +// `invocation_mode`, an optional `chat_room_filter` and, for most of them, a +// `before_publish_config` block. Copying the webhook plumbing for them yields a +// resource with a bogus required `source` block and a `request_mode` the API +// rejects, so they wire to the control client through the CRUD in this file +// instead. +// +// * with one exception: before-publish AWS Lambda takes an optional source. + +// AblyBeforePublishConfig mirrors control.BeforePublishConfig. Before-publish +// rules run before a message is published, so this block controls retry/backoff +// and what happens when the endpoint fails or rate limits. +// +// Kept separate from AblyRuleBodyguardBeforePublishConfig, which predates this +// file: the two are structurally identical, and bodyguard stays on its own model +// so the reference port in resource_ably_rule_bodyguard.go is not disturbed. +type AblyBeforePublishConfig struct { + RetryTimeout types.Int64 `tfsdk:"retry_timeout"` + MaxRetries types.Int64 `tfsdk:"max_retries"` + FailedAction types.String `tfsdk:"failed_action"` + TooManyRequestsAction types.String `tfsdk:"too_many_requests_action"` +} + +// beforePublishConfigPost converts the plan block into the control create body. +// The block is Required in every generated schema that has it, so a nil pointer +// means Terraform gave us a rule family that does not carry one and the zero +// value is correct. +func beforePublishConfigPost(c *AblyBeforePublishConfig) control.BeforePublishConfig { + if c == nil { + return control.BeforePublishConfig{} + } + return control.BeforePublishConfig{ + RetryTimeout: int(c.RetryTimeout.ValueInt64()), + MaxRetries: int(c.MaxRetries.ValueInt64()), + FailedAction: c.FailedAction.ValueString(), + TooManyRequestsAction: c.TooManyRequestsAction.ValueString(), + } +} + +// beforePublishConfigResponse maps the API's before-publish config back onto the +// tfsdk model, returning nil when the response omits it. +func beforePublishConfigResponse(c *control.BeforePublishConfig) *AblyBeforePublishConfig { + if c == nil { + return nil + } + return &AblyBeforePublishConfig{ + RetryTimeout: types.Int64Value(int64(c.RetryTimeout)), + MaxRetries: types.Int64Value(int64(c.MaxRetries)), + FailedAction: types.StringValue(c.FailedAction), + TooManyRequestsAction: types.StringValue(c.TooManyRequestsAction), + } +} + +// thresholdsPost converts a moderation target's thresholds map into the control +// map. Null/unknown becomes nil so the field is omitted from the request body. +func thresholdsPost(ctx context.Context, m types.Map) (map[string]int, diag.Diagnostics) { + var diags diag.Diagnostics + if m.IsNull() || m.IsUnknown() { + return nil, diags + } + elements := map[string]int64{} + diags.Append(m.ElementsAs(ctx, &elements, false)...) + if diags.HasError() { + return nil, diags + } + thresholds := make(map[string]int, len(elements)) + for k, v := range elements { + thresholds[k] = int(v) + } + return thresholds, diags +} + +// thresholdsResponse maps the control thresholds map back onto the tfsdk model, +// returning null when the response omits it so an unset optional attribute +// round-trips as null rather than an empty map. +func thresholdsResponse(ctx context.Context, thresholds map[string]int) (types.Map, diag.Diagnostics) { + if len(thresholds) == 0 { + return types.MapNull(types.Int64Type), nil + } + values := make(map[string]int64, len(thresholds)) + for k, v := range thresholds { + values[k] = int64(v) + } + return types.MapValueFrom(ctx, types.Int64Type, values) +} + +// beforePublishCRUD is the CRUD shared by every moderation/before-publish rule +// resource. The per-resource parts are the create body and the response +// mapping; everything else (error wording, 404 handling, state writes) is +// identical, so each resource supplies the two functions and delegates. +// +// M is the resource's tfsdk model. +type beforePublishCRUD[M any] struct { + p *AblyProvider + // name is the human-readable rule name used in diagnostics. + name string + // appID and id read the parent app and rule IDs out of the model. + appID func(M) string + id func(M) string + // post builds the Control API create/update body from the model. + post func(context.Context, M) (any, diag.Diagnostics) + // response maps an API response onto the model. The second argument is the + // plan (create/update) or prior state (read), for the fields the API does + // not return. + response func(context.Context, *control.RuleResponse, *M) (M, diag.Diagnostics) +} + +func (c beforePublishCRUD[M]) create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + if !c.p.ensureConfigured(&resp.Diagnostics) { + return + } + + var plan M + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + + body, diags := c.post(ctx, plan) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + rule, err := c.p.client.CreateRule(ctx, c.appID(plan), body) + if err != nil { + resp.Diagnostics.AddError( + fmt.Sprintf("Error creating resource %s", c.name), + fmt.Sprintf("Could not create resource %s, unexpected error: %s", c.name, err.Error()), + ) + return + } + + responseValues, respDiags := c.response(ctx, &rule, &plan) + resp.Diagnostics.Append(respDiags...) + if resp.Diagnostics.HasError() { + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, responseValues)...) +} + +func (c beforePublishCRUD[M]) read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state M + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + rule, err := c.p.client.GetRule(ctx, c.appID(state), c.id(state)) + if err != nil { + if is404(err) { + resp.State.RemoveResource(ctx) + return + } + resp.Diagnostics.AddError( + fmt.Sprintf("Error reading resource %s", c.name), + fmt.Sprintf("Could not read resource %s, unexpected error: %s", c.name, err.Error()), + ) + return + } + + responseValues, respDiags := c.response(ctx, &rule, &state) + resp.Diagnostics.Append(respDiags...) + if resp.Diagnostics.HasError() { + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &responseValues)...) +} + +func (c beforePublishCRUD[M]) update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var plan M + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + + body, diags := c.post(ctx, plan) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + rule, err := c.p.client.UpdateRule(ctx, c.appID(plan), c.id(plan), body) + if err != nil { + resp.Diagnostics.AddError( + fmt.Sprintf("Error updating resource %s", c.name), + fmt.Sprintf("Could not update resource %s, unexpected error: %s", c.name, err.Error()), + ) + return + } + + responseValues, respDiags := c.response(ctx, &rule, &plan) + resp.Diagnostics.Append(respDiags...) + if resp.Diagnostics.HasError() { + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &responseValues)...) +} + +func (c beforePublishCRUD[M]) delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var state M + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + err := c.p.client.DeleteRule(ctx, c.appID(state), c.id(state)) + if err != nil { + if is404(err) { + resp.Diagnostics.AddWarning( + fmt.Sprintf("Resource %s does not exist", c.name), + fmt.Sprintf("Resource %s does not exist, it may have already been deleted: %s", c.name, err.Error()), + ) + } else { + resp.Diagnostics.AddError( + fmt.Sprintf("Error deleting resource %s", c.name), + fmt.Sprintf("Could not delete resource %s, unexpected error: %s", c.name, err.Error()), + ) + return + } + } + + resp.State.RemoveResource(ctx) +} + +// stripNestedCustomTypes removes the generated CustomType from the named +// single-nested blocks so a plain-struct tfsdk model reflects cleanly. Every +// port needs this (see DEVELOPMENT.md "Porting a resource onto generated +// code"), so it lives here rather than being repeated per resource. +func stripNestedCustomTypes(s *schema.Schema, names ...string) { + for _, name := range names { + attr, ok := s.Attributes[name].(schema.SingleNestedAttribute) + if !ok { + continue + } + attr.CustomType = nil + s.Attributes[name] = attr + } +} + +// stripSingleNestedCustomType does the same for a single-nested block nested +// inside another single-nested block (the Lambda target's authentication). +func stripSingleNestedCustomType(s *schema.Schema, parent, name string) { + parentAttr, ok := s.Attributes[parent].(schema.SingleNestedAttribute) + if !ok { + return + } + childAttr, ok := parentAttr.Attributes[name].(schema.SingleNestedAttribute) + if !ok { + return + } + childAttr.CustomType = nil + parentAttr.Attributes[name] = childAttr + s.Attributes[parent] = parentAttr +} + +// stripListNestedCustomType does the same for a list-nested block inside a +// single-nested block (the webhook target's headers), where the CustomType sits +// on the list's nested object. +func stripListNestedCustomType(s *schema.Schema, parent, name string) { + parentAttr, ok := s.Attributes[parent].(schema.SingleNestedAttribute) + if !ok { + return + } + listAttr, ok := parentAttr.Attributes[name].(schema.ListNestedAttribute) + if !ok { + return + } + listAttr.NestedObject.CustomType = nil + parentAttr.Attributes[name] = listAttr + s.Attributes[parent] = parentAttr +} + +// checkRuleType guards against the API returning a rule of a different family, +// which would otherwise be silently mis-mapped onto this resource's model. +func checkRuleType(rule *control.RuleResponse, want string) diag.Diagnostics { + var diags diag.Diagnostics + if rule.RuleType != want { + diags.AddError( + "Unexpected rule type in response", + fmt.Sprintf("Expected rule type %q but received %q", want, rule.RuleType), + ) + } + return diags +} diff --git a/internal/provider/before_publish_rules_unit_test.go b/internal/provider/before_publish_rules_unit_test.go new file mode 100644 index 0000000..fe0d732 --- /dev/null +++ b/internal/provider/before_publish_rules_unit_test.go @@ -0,0 +1,450 @@ +// Package provider implements the Ably provider for Terraform +package provider + +import ( + "context" + "encoding/json" + "testing" + + "github.com/ably/terraform-provider-ably/control" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +// sampleBeforePublishConfig returns a fully-populated before-publish config for +// use in the tests below. +func sampleBeforePublishConfig() *AblyBeforePublishConfig { + return &AblyBeforePublishConfig{ + RetryTimeout: types.Int64Value(5000), + MaxRetries: types.Int64Value(3), + FailedAction: types.StringValue("PUBLISH"), + TooManyRequestsAction: types.StringValue("RETRY"), + } +} + +// sampleThresholds returns a thresholds map as Terraform holds it. +func sampleThresholds(t *testing.T) types.Map { + t.Helper() + m, diags := types.MapValueFrom(context.Background(), types.Int64Type, map[string]int64{"abuse": 2}) + if diags.HasError() { + t.Fatalf("unexpected diagnostics building thresholds: %s", diags.Errors()[0].Detail()) + } + return m +} + +// wireBody marshals a create body and returns its top-level fields, so the tests +// can assert the exact tokens the Control API accepts. The echoing fake cannot +// catch a wrong enum or a stray webhook field; this can. +func wireBody(t *testing.T, body any) map[string]json.RawMessage { + t.Helper() + data, err := json.Marshal(body) + if err != nil { + t.Fatalf("marshal error: %v", err) + } + var raw map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + t.Fatalf("unmarshal error: %v", err) + } + return raw +} + +// assertNoWebhookFields guards the keystone footgun for this family: the +// dominant webhook pattern bakes in source/requestMode, which the moderation and +// before-publish APIs reject. +func assertNoWebhookFields(t *testing.T, raw map[string]json.RawMessage) { + t.Helper() + if _, ok := raw["requestMode"]; ok { + t.Error("body must not contain a requestMode field") + } + if _, ok := raw["source"]; ok { + t.Error("body must not contain a source field") + } +} + +// TestModerationRulePosts_Discriminators verifies each moderation rule sends its +// own ruleType discriminator with the moderation-shaped fields and none of the +// webhook ones. +func TestModerationRulePosts_Discriminators(t *testing.T) { + t.Parallel() + + ctx := context.Background() + thresholds := sampleThresholds(t) + + tisane, diags := getPlanTisanePost(ctx, AblyRuleTisane{ + Status: types.StringValue("enabled"), + InvocationMode: types.StringValue("BEFORE_PUBLISH"), + ChatRoomFilter: types.StringValue("/room-.*/"), + BeforePublishConfig: sampleBeforePublishConfig(), + Target: &AblyRuleTisaneTarget{ + ApiKey: types.StringValue("secret-key"), + ModelURL: types.StringNull(), + Thresholds: thresholds, + DefaultLanguage: types.StringValue("en"), + }, + }) + if diags.HasError() { + t.Fatalf("tisane post diagnostics: %s", diags.Errors()[0].Detail()) + } + + azure, diags := getPlanAzureModerationPost(ctx, AblyRuleAzureModeration{ + Status: types.StringValue("enabled"), + InvocationMode: types.StringValue("BEFORE_PUBLISH"), + BeforePublishConfig: sampleBeforePublishConfig(), + Target: &AblyRuleAzureModerationTarget{ + ApiKey: types.StringValue("secret-key"), + Endpoint: types.StringValue("https://my-resource.cognitiveservices.azure.com"), + Thresholds: thresholds, + }, + }) + if diags.HasError() { + t.Fatalf("azure post diagnostics: %s", diags.Errors()[0].Detail()) + } + + hiveText, diags := getPlanHiveTextPost(ctx, AblyRuleHiveText{ + Status: types.StringValue("enabled"), + InvocationMode: types.StringValue("BEFORE_PUBLISH"), + BeforePublishConfig: sampleBeforePublishConfig(), + Target: &AblyRuleHiveTextTarget{ + ApiKey: types.StringValue("secret-key"), + ModelURL: types.StringNull(), + Thresholds: thresholds, + }, + }) + if diags.HasError() { + t.Fatalf("hive text post diagnostics: %s", diags.Errors()[0].Detail()) + } + + // Hive dashboard is the one rule in the family that runs after publish. + hiveDashboard, diags := getPlanHiveDashboardPost(ctx, AblyRuleHiveDashboard{ + Status: types.StringValue("enabled"), + InvocationMode: types.StringValue("AFTER_PUBLISH"), + Target: &AblyRuleHiveDashboardTarget{ + ApiKey: types.StringValue("secret-key"), + CheckWatchLists: types.BoolValue(true), + }, + }) + if diags.HasError() { + t.Fatalf("hive dashboard post diagnostics: %s", diags.Errors()[0].Detail()) + } + + webhook, diags := getPlanBeforePublishWebhookPost(ctx, AblyRuleBeforePublishWebhook{ + Status: types.StringValue("enabled"), + InvocationMode: types.StringValue("BEFORE_PUBLISH"), + BeforePublishConfig: sampleBeforePublishConfig(), + Target: &AblyRuleBeforePublishWebhookTarget{ + Url: types.StringValue("https://example.com/moderate"), + Headers: []AblyRuleHeaders{{ + Name: types.StringValue("X-Custom-Header"), + Value: types.StringValue("custom-header-value"), + }}, + }, + }) + if diags.HasError() { + t.Fatalf("before-publish webhook post diagnostics: %s", diags.Errors()[0].Detail()) + } + + for _, tc := range []struct { + name string + body any + ruleType string + invocationMode string + }{ + {"tisane", tisane, "tisane/text-moderation", "BEFORE_PUBLISH"}, + {"azure", azure, "azure/text-moderation", "BEFORE_PUBLISH"}, + {"hive text", hiveText, "hive/text-model-only", "BEFORE_PUBLISH"}, + {"hive dashboard", hiveDashboard, "hive/dashboard", "AFTER_PUBLISH"}, + {"before-publish webhook", webhook, "http/before-publish", "BEFORE_PUBLISH"}, + } { + raw := wireBody(t, tc.body) + if got := string(raw["ruleType"]); got != `"`+tc.ruleType+`"` { + t.Errorf("%s ruleType wire value = %s, want %q", tc.name, got, tc.ruleType) + } + if got := string(raw["invocationMode"]); got != `"`+tc.invocationMode+`"` { + t.Errorf("%s invocationMode wire value = %s, want %q", tc.name, got, tc.invocationMode) + } + assertNoWebhookFields(t, raw) + } + + // Hive dashboard is the one rule in the family with no before-publish + // config; sending one would be a copied-family bug. + if _, ok := wireBody(t, hiveDashboard)["beforePublishConfig"]; ok { + t.Error("hive/dashboard body must not contain beforePublishConfig") + } + // The other four must send it, with the exact enum casing. + for name, body := range map[string]any{"tisane": tisane, "azure": azure, "hive text": hiveText, "webhook": webhook} { + var bpc struct { + FailedAction string `json:"failedAction"` + TooManyRequestsAction string `json:"tooManyRequestsAction"` + RetryTimeout int `json:"retryTimeout"` + } + raw, ok := wireBody(t, body)["beforePublishConfig"] + if !ok { + t.Errorf("%s body is missing beforePublishConfig", name) + continue + } + if err := json.Unmarshal(raw, &bpc); err != nil { + t.Fatalf("%s beforePublishConfig unmarshal error: %v", name, err) + } + if bpc.FailedAction != "PUBLISH" || bpc.TooManyRequestsAction != "RETRY" || bpc.RetryTimeout != 5000 { + t.Errorf("%s beforePublishConfig = %+v, want PUBLISH/RETRY/5000", name, bpc) + } + } +} + +// TestThresholdsRoundTrip verifies the thresholds map survives the trip to the +// control type and back, and that an absent map is null rather than empty (an +// empty map in state against a null plan is a permanent diff). +func TestThresholdsRoundTrip(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + thresholds, diags := thresholdsPost(ctx, sampleThresholds(t)) + if diags.HasError() { + t.Fatalf("unexpected diagnostics: %s", diags.Errors()[0].Detail()) + } + if thresholds["abuse"] != 2 { + t.Fatalf("thresholds = %v, want abuse=2", thresholds) + } + + back, diags := thresholdsResponse(ctx, thresholds) + if diags.HasError() { + t.Fatalf("unexpected diagnostics: %s", diags.Errors()[0].Detail()) + } + if len(back.Elements()) != 1 { + t.Fatalf("mapped thresholds = %v, want one element", back) + } + + nilThresholds, diags := thresholdsPost(ctx, types.MapNull(types.Int64Type)) + if diags.HasError() { + t.Fatalf("unexpected diagnostics: %s", diags.Errors()[0].Detail()) + } + if nilThresholds != nil { + t.Fatalf("null thresholds must not serialize, got %v", nilThresholds) + } + + nullBack, diags := thresholdsResponse(ctx, nil) + if diags.HasError() { + t.Fatalf("unexpected diagnostics: %s", diags.Errors()[0].Detail()) + } + if !nullBack.IsNull() { + t.Fatalf("absent thresholds must map to null, got %v", nullBack) + } +} + +// TestGetTisaneResponse_AbsentOptionalsAreNull verifies a response without +// chatRoomFilter, modelUrl or thresholds maps them to null, matching a plan that +// never set them. +func TestGetTisaneResponse_AbsentOptionalsAreNull(t *testing.T) { + t.Parallel() + + rule := control.RuleResponse{ + ID: "rule-1", + AppID: "app-123", + Status: "enabled", + RuleType: "tisane/text-moderation", + InvocationMode: "BEFORE_PUBLISH", + BeforePublishConfig: &control.BeforePublishConfig{ + RetryTimeout: 5000, MaxRetries: 3, FailedAction: "PUBLISH", TooManyRequestsAction: "RETRY", + }, + Target: map[string]any{"apiKey": "resp-key", "defaultLanguage": "en"}, + } + + got, diags := getTisaneResponse(context.Background(), &rule, nil) + if diags.HasError() { + t.Fatalf("unexpected diagnostics: %s", diags.Errors()[0].Detail()) + } + if !got.ChatRoomFilter.IsNull() { + t.Errorf("chat_room_filter = %q, want null", got.ChatRoomFilter.ValueString()) + } + if !got.Target.ModelURL.IsNull() { + t.Errorf("model_url = %q, want null", got.Target.ModelURL.ValueString()) + } + if !got.Target.Thresholds.IsNull() { + t.Errorf("thresholds = %v, want null", got.Target.Thresholds) + } + if got.Target.ApiKey.ValueString() != "resp-key" { + t.Errorf("api_key = %q, want resp-key", got.Target.ApiKey.ValueString()) + } + if got.BeforePublishConfig == nil || got.BeforePublishConfig.MaxRetries.ValueInt64() != 3 { + t.Errorf("before_publish_config = %+v, want max_retries 3 from the response", got.BeforePublishConfig) + } +} + +// TestGetHiveTextResponse_WrongRuleType ensures a mismatched discriminator in the +// response is surfaced as an error rather than silently mis-mapped, so a rule of +// another family imported by ID does not land in this resource's state. +func TestGetHiveTextResponse_WrongRuleType(t *testing.T) { + t.Parallel() + + rule := control.RuleResponse{ID: "rule-1", RuleType: "http", Target: map[string]any{}} + + if _, diags := getHiveTextResponse(context.Background(), &rule, nil); !diags.HasError() { + t.Fatal("expected an error for a non-hive rule type, got none") + } +} + +// TestGetPlanBeforePublishLambdaPost_AuthModes verifies each AWS auth mode sends +// only its own fields: the API rejects credentials alongside an assume-role ARN. +func TestGetPlanBeforePublishLambdaPost_AuthModes(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + credentials, diags := getPlanBeforePublishLambdaPost(ctx, sampleBeforePublishLambdaPlan()) + if diags.HasError() { + t.Fatalf("unexpected diagnostics: %s", diags.Errors()[0].Detail()) + } + raw := wireBody(t, credentials) + if got := string(raw["ruleType"]); got != `"aws/lambda/before-publish"` { + t.Errorf("ruleType wire value = %s, want \"aws/lambda/before-publish\"", got) + } + if _, ok := raw["requestMode"]; ok { + t.Error("body must not contain a requestMode field") + } + // This is the one before-publish family that does take a source. + if _, ok := raw["source"]; !ok { + t.Error("body must carry the source when the plan sets one") + } + + var target struct { + Authentication control.AWSAuthentication `json:"authentication"` + } + if err := json.Unmarshal(raw["target"], &target); err != nil { + t.Fatalf("target unmarshal error: %v", err) + } + if target.Authentication.AuthenticationMode != "credentials" { + t.Errorf("authenticationMode = %q, want credentials", target.Authentication.AuthenticationMode) + } + if target.Authentication.SecretAccessKey != "secret" { + t.Errorf("secretAccessKey = %q, want secret", target.Authentication.SecretAccessKey) + } + if target.Authentication.AssumeRoleArn != "" { + t.Errorf("assumeRoleArn = %q, want empty in credentials mode", target.Authentication.AssumeRoleArn) + } + + plan := sampleBeforePublishLambdaPlan() + plan.Source = types.ObjectNull(beforePublishLambdaSourceAttrTypes) + plan.Target.Authentication = &AblyRuleBeforePublishLambdaAuth{ + AuthenticationMode: types.StringValue("assumeRole"), + AssumeRoleArn: types.StringValue("arn:aws:iam::123456789012:role/ably-moderation"), + } + assumeRole, diags := getPlanBeforePublishLambdaPost(ctx, plan) + if diags.HasError() { + t.Fatalf("unexpected diagnostics: %s", diags.Errors()[0].Detail()) + } + raw = wireBody(t, assumeRole) + if _, ok := raw["source"]; ok { + t.Error("body must omit the source when the plan has none") + } + // Reset before decoding: json.Unmarshal leaves fields absent from the body + // untouched, which would carry the credentials over from the assertions above. + target.Authentication = control.AWSAuthentication{} + if err := json.Unmarshal(raw["target"], &target); err != nil { + t.Fatalf("target unmarshal error: %v", err) + } + if target.Authentication.AuthenticationMode != "assumeRole" { + t.Errorf("authenticationMode = %q, want assumeRole", target.Authentication.AuthenticationMode) + } + if target.Authentication.AccessKeyID != "" || target.Authentication.SecretAccessKey != "" { + t.Errorf("credentials leaked into assumeRole body: %+v", target.Authentication) + } +} + +// TestGetBeforePublishLambdaResponse_PreservesSecret is the loud test for the +// write-only carve-out: the Control API never returns secretAccessKey, so it has +// to come from the plan (create/update) or prior state (read). Reading it back as +// null aborts the apply with "inconsistent result after apply" on a sensitive +// attribute, which is exactly the failure mode this guards. +func TestGetBeforePublishLambdaResponse_PreservesSecret(t *testing.T) { + t.Parallel() + + prior := sampleBeforePublishLambdaPlan() + rule := control.RuleResponse{ + ID: "rule-1", + AppID: "app-123", + Status: "enabled", + RuleType: "aws/lambda/before-publish", + InvocationMode: "BEFORE_PUBLISH", + BeforePublishConfig: &control.BeforePublishConfig{ + RetryTimeout: 5000, MaxRetries: 3, FailedAction: "PUBLISH", TooManyRequestsAction: "RETRY", + }, + Target: map[string]any{ + "region": "us-west-1", + "functionName": "my-moderation-function", + // As the API returns it: the secret is absent. + "authentication": map[string]any{ + "authenticationMode": "credentials", + "accessKeyId": "AKIAIOSFODNN7EXAMPLE", + }, + }, + } + + got, diags := getBeforePublishLambdaResponse(context.Background(), &rule, &prior) + if diags.HasError() { + t.Fatalf("unexpected diagnostics: %s", diags.Errors()[0].Detail()) + } + if got.Target.Authentication.SecretAccessKey.ValueString() != "secret" { + t.Fatalf("secret_access_key = %q, want the value preserved from the plan", got.Target.Authentication.SecretAccessKey.ValueString()) + } + if got.Target.Authentication.AccessKeyID.ValueString() != "AKIAIOSFODNN7EXAMPLE" { + t.Errorf("access_key_id = %q, want it read back from the response", got.Target.Authentication.AccessKeyID.ValueString()) + } + if !got.Target.Authentication.AssumeRoleArn.IsNull() { + t.Errorf("assume_role_arn = %q, want null in credentials mode", got.Target.Authentication.AssumeRoleArn.ValueString()) + } + + // Switching to assumeRole must clear the credentials rather than leaving the + // plan's values behind in state. + rule.Target = map[string]any{ + "region": "us-west-1", + "functionName": "my-moderation-function", + "authentication": map[string]any{ + "authenticationMode": "assumeRole", + "assumeRoleArn": "arn:aws:iam::123456789012:role/ably-moderation", + }, + } + got, diags = getBeforePublishLambdaResponse(context.Background(), &rule, &prior) + if diags.HasError() { + t.Fatalf("unexpected diagnostics: %s", diags.Errors()[0].Detail()) + } + if !got.Target.Authentication.SecretAccessKey.IsNull() || !got.Target.Authentication.AccessKeyID.IsNull() { + t.Fatalf("credentials survived a switch to assumeRole: %+v", got.Target.Authentication) + } + if got.Target.Authentication.AssumeRoleArn.ValueString() != "arn:aws:iam::123456789012:role/ably-moderation" { + t.Errorf("assume_role_arn = %q, want it read back from the response", got.Target.Authentication.AssumeRoleArn.ValueString()) + } +} + +// sampleBeforePublishLambdaSource returns the source block as Terraform holds +// it. chat.message is the only value the API accepts for a before-publish rule +// (see the source override in codegen/ruletypesgen). +func sampleBeforePublishLambdaSource() types.Object { + source, diags := types.ObjectValueFrom(context.Background(), beforePublishLambdaSourceAttrTypes, AblyChatMessageSource{ + Type: types.StringValue("chat.message"), + }) + if diags.HasError() { + panic(diags.Errors()[0].Detail()) + } + return source +} + +// sampleBeforePublishLambdaPlan returns a credentials-mode Lambda plan. +func sampleBeforePublishLambdaPlan() AblyRuleBeforePublishLambda { + return AblyRuleBeforePublishLambda{ + ID: types.StringValue("rule-1"), + AppID: types.StringValue("app-123"), + Status: types.StringValue("enabled"), + InvocationMode: types.StringValue("BEFORE_PUBLISH"), + BeforePublishConfig: sampleBeforePublishConfig(), + Source: sampleBeforePublishLambdaSource(), + Target: &AblyRuleBeforePublishLambdaTarget{ + Region: types.StringValue("us-west-1"), + FunctionName: types.StringValue("my-moderation-function"), + Authentication: &AblyRuleBeforePublishLambdaAuth{ + AuthenticationMode: types.StringValue("credentials"), + AccessKeyID: types.StringValue("AKIAIOSFODNN7EXAMPLE"), + SecretAccessKey: types.StringValue("secret"), + }, + }, + } +} diff --git a/internal/provider/codegen/datasource_apps/apps_data_source_gen.go b/internal/provider/codegen/datasource_apps/apps_data_source_gen.go new file mode 100644 index 0000000..3508d33 --- /dev/null +++ b/internal/provider/codegen/datasource_apps/apps_data_source_gen.go @@ -0,0 +1,1627 @@ +// Code generated by terraform-plugin-framework-generator DO NOT EDIT. + +package datasource_apps + +import ( + "context" + "fmt" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" + "github.com/hashicorp/terraform-plugin-go/tftypes" + "strings" + + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" +) + +func AppsDataSourceSchema(ctx context.Context) schema.Schema { + return schema.Schema{ + Attributes: map[string]schema.Attribute{ + "account_id": schema.StringAttribute{ + Required: true, + Description: "The account ID for which to retrieve the associated applications.", + MarkdownDescription: "The account ID for which to retrieve the associated applications.", + }, + "apps": schema.SetNestedAttribute{ + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "_links": schema.SingleNestedAttribute{ + Attributes: map[string]schema.Attribute{}, + CustomType: LinksType{ + ObjectType: types.ObjectType{ + AttrTypes: LinksValue{}.AttributeTypes(ctx), + }, + }, + Computed: true, + Description: "A link self-referencing the app that has been created.", + MarkdownDescription: "A link self-referencing the app that has been created.", + }, + "account_id": schema.StringAttribute{ + Computed: true, + Description: "The ID of your Ably account.", + MarkdownDescription: "The ID of your Ably account.", + }, + "apns_auth_type": schema.StringAttribute{ + Computed: true, + Description: "The Apple Push Notification service authentication type. Use `certificate` for p12 certificate-based auth, or `token` for p8 key-based auth.", + MarkdownDescription: "The Apple Push Notification service authentication type. Use `certificate` for p12 certificate-based auth, or `token` for p8 key-based auth.", + }, + "apns_certificate_configured": schema.BoolAttribute{ + Computed: true, + Description: "Whether a certificate-based APNs configuration is currently active.", + MarkdownDescription: "Whether a certificate-based APNs configuration is currently active.", + }, + "apns_issuer_key": schema.StringAttribute{ + Computed: true, + Description: "The Team ID from the Apple Developer account. Used as the issuer when generating APNs authentication tokens.", + MarkdownDescription: "The Team ID from the Apple Developer account. Used as the issuer when generating APNs authentication tokens.", + }, + "apns_signing_key_configured": schema.BoolAttribute{ + Computed: true, + Description: "Whether a token-based (p8) APNs signing key is currently configured.", + MarkdownDescription: "Whether a token-based (p8) APNs signing key is currently configured.", + }, + "apns_signing_key_id": schema.StringAttribute{ + Computed: true, + Description: "The 10-character Key ID from the Apple Developer portal, associated with the `.p8` signing key.", + MarkdownDescription: "The 10-character Key ID from the Apple Developer portal, associated with the `.p8` signing key.", + }, + "apns_topic_header": schema.StringAttribute{ + Computed: true, + Description: "The app's bundle ID (for example, `com.example.myapp`). Used as the `apns-topic` header when sending push notifications.", + MarkdownDescription: "The app's bundle ID (for example, `com.example.myapp`). Used as the `apns-topic` header when sending push notifications.", + }, + "apns_use_sandbox_endpoint": schema.BoolAttribute{ + Computed: true, + Description: "Use the Apple Push Notification service sandbox endpoint.", + MarkdownDescription: "Use the Apple Push Notification service sandbox endpoint.", + }, + "created": schema.Int64Attribute{ + Computed: true, + Description: "Unix timestamp representing the date and time of creation of the application.", + MarkdownDescription: "Unix timestamp representing the date and time of creation of the application.", + }, + "fcm_project_id": schema.StringAttribute{ + Computed: true, + Description: "The unique identifier for the Firebase Cloud Messaging (FCM) project. This ID is used to specify the Firebase project when configuring FCM or other Firebase services.", + MarkdownDescription: "The unique identifier for the Firebase Cloud Messaging (FCM) project. This ID is used to specify the Firebase project when configuring FCM or other Firebase services.", + }, + "fcm_service_account_configured": schema.BoolAttribute{ + Computed: true, + Description: "Whether Firebase Cloud Messaging (FCM) service account credentials are currently configured.", + MarkdownDescription: "Whether Firebase Cloud Messaging (FCM) service account credentials are currently configured.", + }, + "id": schema.StringAttribute{ + Computed: true, + Description: "The application ID.", + MarkdownDescription: "The application ID.", + }, + "modified": schema.Int64Attribute{ + Computed: true, + Description: "Unix timestamp representing the date and time of last modification of the application.", + MarkdownDescription: "Unix timestamp representing the date and time of last modification of the application.", + }, + "name": schema.StringAttribute{ + Computed: true, + Description: "The application name.", + MarkdownDescription: "The application name.", + }, + "status": schema.StringAttribute{ + Computed: true, + Description: "The status of the application. Can be `enabled` or `disabled`. Enabled means available to accept inbound connections and all services are available.", + MarkdownDescription: "The status of the application. Can be `enabled` or `disabled`. Enabled means available to accept inbound connections and all services are available.", + }, + "tls_only": schema.BoolAttribute{ + Computed: true, + Description: "Enforce TLS for all connections. This setting overrides any channel setting.", + MarkdownDescription: "Enforce TLS for all connections. This setting overrides any channel setting.", + }, + }, + CustomType: AppsType{ + ObjectType: types.ObjectType{ + AttrTypes: AppsValue{}.AttributeTypes(ctx), + }, + }, + }, + Computed: true, + }, + }, + } +} + +type AppsModel struct { + AccountId types.String `tfsdk:"account_id"` + Apps types.Set `tfsdk:"apps"` +} + +var _ basetypes.ObjectTypable = AppsType{} + +type AppsType struct { + basetypes.ObjectType +} + +func (t AppsType) Equal(o attr.Type) bool { + other, ok := o.(AppsType) + + if !ok { + return false + } + + return t.ObjectType.Equal(other.ObjectType) +} + +func (t AppsType) String() string { + return "AppsType" +} + +func (t AppsType) ValueFromObject(ctx context.Context, in basetypes.ObjectValue) (basetypes.ObjectValuable, diag.Diagnostics) { + var diags diag.Diagnostics + + attributes := in.Attributes() + + linksAttribute, ok := attributes["_links"] + + if !ok { + diags.AddError( + "Attribute Missing", + `_links is missing from object`) + + return nil, diags + } + + linksVal, ok := linksAttribute.(basetypes.ObjectValue) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`_links expected to be basetypes.ObjectValue, was: %T`, linksAttribute)) + } + + accountIdAttribute, ok := attributes["account_id"] + + if !ok { + diags.AddError( + "Attribute Missing", + `account_id is missing from object`) + + return nil, diags + } + + accountIdVal, ok := accountIdAttribute.(basetypes.StringValue) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`account_id expected to be basetypes.StringValue, was: %T`, accountIdAttribute)) + } + + apnsAuthTypeAttribute, ok := attributes["apns_auth_type"] + + if !ok { + diags.AddError( + "Attribute Missing", + `apns_auth_type is missing from object`) + + return nil, diags + } + + apnsAuthTypeVal, ok := apnsAuthTypeAttribute.(basetypes.StringValue) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`apns_auth_type expected to be basetypes.StringValue, was: %T`, apnsAuthTypeAttribute)) + } + + apnsCertificateConfiguredAttribute, ok := attributes["apns_certificate_configured"] + + if !ok { + diags.AddError( + "Attribute Missing", + `apns_certificate_configured is missing from object`) + + return nil, diags + } + + apnsCertificateConfiguredVal, ok := apnsCertificateConfiguredAttribute.(basetypes.BoolValue) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`apns_certificate_configured expected to be basetypes.BoolValue, was: %T`, apnsCertificateConfiguredAttribute)) + } + + apnsIssuerKeyAttribute, ok := attributes["apns_issuer_key"] + + if !ok { + diags.AddError( + "Attribute Missing", + `apns_issuer_key is missing from object`) + + return nil, diags + } + + apnsIssuerKeyVal, ok := apnsIssuerKeyAttribute.(basetypes.StringValue) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`apns_issuer_key expected to be basetypes.StringValue, was: %T`, apnsIssuerKeyAttribute)) + } + + apnsSigningKeyConfiguredAttribute, ok := attributes["apns_signing_key_configured"] + + if !ok { + diags.AddError( + "Attribute Missing", + `apns_signing_key_configured is missing from object`) + + return nil, diags + } + + apnsSigningKeyConfiguredVal, ok := apnsSigningKeyConfiguredAttribute.(basetypes.BoolValue) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`apns_signing_key_configured expected to be basetypes.BoolValue, was: %T`, apnsSigningKeyConfiguredAttribute)) + } + + apnsSigningKeyIdAttribute, ok := attributes["apns_signing_key_id"] + + if !ok { + diags.AddError( + "Attribute Missing", + `apns_signing_key_id is missing from object`) + + return nil, diags + } + + apnsSigningKeyIdVal, ok := apnsSigningKeyIdAttribute.(basetypes.StringValue) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`apns_signing_key_id expected to be basetypes.StringValue, was: %T`, apnsSigningKeyIdAttribute)) + } + + apnsTopicHeaderAttribute, ok := attributes["apns_topic_header"] + + if !ok { + diags.AddError( + "Attribute Missing", + `apns_topic_header is missing from object`) + + return nil, diags + } + + apnsTopicHeaderVal, ok := apnsTopicHeaderAttribute.(basetypes.StringValue) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`apns_topic_header expected to be basetypes.StringValue, was: %T`, apnsTopicHeaderAttribute)) + } + + apnsUseSandboxEndpointAttribute, ok := attributes["apns_use_sandbox_endpoint"] + + if !ok { + diags.AddError( + "Attribute Missing", + `apns_use_sandbox_endpoint is missing from object`) + + return nil, diags + } + + apnsUseSandboxEndpointVal, ok := apnsUseSandboxEndpointAttribute.(basetypes.BoolValue) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`apns_use_sandbox_endpoint expected to be basetypes.BoolValue, was: %T`, apnsUseSandboxEndpointAttribute)) + } + + createdAttribute, ok := attributes["created"] + + if !ok { + diags.AddError( + "Attribute Missing", + `created is missing from object`) + + return nil, diags + } + + createdVal, ok := createdAttribute.(basetypes.Int64Value) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`created expected to be basetypes.Int64Value, was: %T`, createdAttribute)) + } + + fcmProjectIdAttribute, ok := attributes["fcm_project_id"] + + if !ok { + diags.AddError( + "Attribute Missing", + `fcm_project_id is missing from object`) + + return nil, diags + } + + fcmProjectIdVal, ok := fcmProjectIdAttribute.(basetypes.StringValue) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`fcm_project_id expected to be basetypes.StringValue, was: %T`, fcmProjectIdAttribute)) + } + + fcmServiceAccountConfiguredAttribute, ok := attributes["fcm_service_account_configured"] + + if !ok { + diags.AddError( + "Attribute Missing", + `fcm_service_account_configured is missing from object`) + + return nil, diags + } + + fcmServiceAccountConfiguredVal, ok := fcmServiceAccountConfiguredAttribute.(basetypes.BoolValue) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`fcm_service_account_configured expected to be basetypes.BoolValue, was: %T`, fcmServiceAccountConfiguredAttribute)) + } + + idAttribute, ok := attributes["id"] + + if !ok { + diags.AddError( + "Attribute Missing", + `id is missing from object`) + + return nil, diags + } + + idVal, ok := idAttribute.(basetypes.StringValue) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`id expected to be basetypes.StringValue, was: %T`, idAttribute)) + } + + modifiedAttribute, ok := attributes["modified"] + + if !ok { + diags.AddError( + "Attribute Missing", + `modified is missing from object`) + + return nil, diags + } + + modifiedVal, ok := modifiedAttribute.(basetypes.Int64Value) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`modified expected to be basetypes.Int64Value, was: %T`, modifiedAttribute)) + } + + nameAttribute, ok := attributes["name"] + + if !ok { + diags.AddError( + "Attribute Missing", + `name is missing from object`) + + return nil, diags + } + + nameVal, ok := nameAttribute.(basetypes.StringValue) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`name expected to be basetypes.StringValue, was: %T`, nameAttribute)) + } + + statusAttribute, ok := attributes["status"] + + if !ok { + diags.AddError( + "Attribute Missing", + `status is missing from object`) + + return nil, diags + } + + statusVal, ok := statusAttribute.(basetypes.StringValue) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`status expected to be basetypes.StringValue, was: %T`, statusAttribute)) + } + + tlsOnlyAttribute, ok := attributes["tls_only"] + + if !ok { + diags.AddError( + "Attribute Missing", + `tls_only is missing from object`) + + return nil, diags + } + + tlsOnlyVal, ok := tlsOnlyAttribute.(basetypes.BoolValue) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`tls_only expected to be basetypes.BoolValue, was: %T`, tlsOnlyAttribute)) + } + + if diags.HasError() { + return nil, diags + } + + return AppsValue{ + Links: linksVal, + AccountId: accountIdVal, + ApnsAuthType: apnsAuthTypeVal, + ApnsCertificateConfigured: apnsCertificateConfiguredVal, + ApnsIssuerKey: apnsIssuerKeyVal, + ApnsSigningKeyConfigured: apnsSigningKeyConfiguredVal, + ApnsSigningKeyId: apnsSigningKeyIdVal, + ApnsTopicHeader: apnsTopicHeaderVal, + ApnsUseSandboxEndpoint: apnsUseSandboxEndpointVal, + Created: createdVal, + FcmProjectId: fcmProjectIdVal, + FcmServiceAccountConfigured: fcmServiceAccountConfiguredVal, + Id: idVal, + Modified: modifiedVal, + Name: nameVal, + Status: statusVal, + TlsOnly: tlsOnlyVal, + state: attr.ValueStateKnown, + }, diags +} + +func NewAppsValueNull() AppsValue { + return AppsValue{ + state: attr.ValueStateNull, + } +} + +func NewAppsValueUnknown() AppsValue { + return AppsValue{ + state: attr.ValueStateUnknown, + } +} + +func NewAppsValue(attributeTypes map[string]attr.Type, attributes map[string]attr.Value) (AppsValue, diag.Diagnostics) { + var diags diag.Diagnostics + + // Reference: https://github.com/hashicorp/terraform-plugin-framework/issues/521 + ctx := context.Background() + + for name, attributeType := range attributeTypes { + attribute, ok := attributes[name] + + if !ok { + diags.AddError( + "Missing AppsValue Attribute Value", + "While creating a AppsValue value, a missing attribute value was detected. "+ + "A AppsValue must contain values for all attributes, even if null or unknown. "+ + "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ + fmt.Sprintf("AppsValue Attribute Name (%s) Expected Type: %s", name, attributeType.String()), + ) + + continue + } + + if !attributeType.Equal(attribute.Type(ctx)) { + diags.AddError( + "Invalid AppsValue Attribute Type", + "While creating a AppsValue value, an invalid attribute value was detected. "+ + "A AppsValue must use a matching attribute type for the value. "+ + "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ + fmt.Sprintf("AppsValue Attribute Name (%s) Expected Type: %s\n", name, attributeType.String())+ + fmt.Sprintf("AppsValue Attribute Name (%s) Given Type: %s", name, attribute.Type(ctx)), + ) + } + } + + for name := range attributes { + _, ok := attributeTypes[name] + + if !ok { + diags.AddError( + "Extra AppsValue Attribute Value", + "While creating a AppsValue value, an extra attribute value was detected. "+ + "A AppsValue must not contain values beyond the expected attribute types. "+ + "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ + fmt.Sprintf("Extra AppsValue Attribute Name: %s", name), + ) + } + } + + if diags.HasError() { + return NewAppsValueUnknown(), diags + } + + linksAttribute, ok := attributes["_links"] + + if !ok { + diags.AddError( + "Attribute Missing", + `_links is missing from object`) + + return NewAppsValueUnknown(), diags + } + + linksVal, ok := linksAttribute.(basetypes.ObjectValue) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`_links expected to be basetypes.ObjectValue, was: %T`, linksAttribute)) + } + + accountIdAttribute, ok := attributes["account_id"] + + if !ok { + diags.AddError( + "Attribute Missing", + `account_id is missing from object`) + + return NewAppsValueUnknown(), diags + } + + accountIdVal, ok := accountIdAttribute.(basetypes.StringValue) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`account_id expected to be basetypes.StringValue, was: %T`, accountIdAttribute)) + } + + apnsAuthTypeAttribute, ok := attributes["apns_auth_type"] + + if !ok { + diags.AddError( + "Attribute Missing", + `apns_auth_type is missing from object`) + + return NewAppsValueUnknown(), diags + } + + apnsAuthTypeVal, ok := apnsAuthTypeAttribute.(basetypes.StringValue) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`apns_auth_type expected to be basetypes.StringValue, was: %T`, apnsAuthTypeAttribute)) + } + + apnsCertificateConfiguredAttribute, ok := attributes["apns_certificate_configured"] + + if !ok { + diags.AddError( + "Attribute Missing", + `apns_certificate_configured is missing from object`) + + return NewAppsValueUnknown(), diags + } + + apnsCertificateConfiguredVal, ok := apnsCertificateConfiguredAttribute.(basetypes.BoolValue) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`apns_certificate_configured expected to be basetypes.BoolValue, was: %T`, apnsCertificateConfiguredAttribute)) + } + + apnsIssuerKeyAttribute, ok := attributes["apns_issuer_key"] + + if !ok { + diags.AddError( + "Attribute Missing", + `apns_issuer_key is missing from object`) + + return NewAppsValueUnknown(), diags + } + + apnsIssuerKeyVal, ok := apnsIssuerKeyAttribute.(basetypes.StringValue) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`apns_issuer_key expected to be basetypes.StringValue, was: %T`, apnsIssuerKeyAttribute)) + } + + apnsSigningKeyConfiguredAttribute, ok := attributes["apns_signing_key_configured"] + + if !ok { + diags.AddError( + "Attribute Missing", + `apns_signing_key_configured is missing from object`) + + return NewAppsValueUnknown(), diags + } + + apnsSigningKeyConfiguredVal, ok := apnsSigningKeyConfiguredAttribute.(basetypes.BoolValue) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`apns_signing_key_configured expected to be basetypes.BoolValue, was: %T`, apnsSigningKeyConfiguredAttribute)) + } + + apnsSigningKeyIdAttribute, ok := attributes["apns_signing_key_id"] + + if !ok { + diags.AddError( + "Attribute Missing", + `apns_signing_key_id is missing from object`) + + return NewAppsValueUnknown(), diags + } + + apnsSigningKeyIdVal, ok := apnsSigningKeyIdAttribute.(basetypes.StringValue) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`apns_signing_key_id expected to be basetypes.StringValue, was: %T`, apnsSigningKeyIdAttribute)) + } + + apnsTopicHeaderAttribute, ok := attributes["apns_topic_header"] + + if !ok { + diags.AddError( + "Attribute Missing", + `apns_topic_header is missing from object`) + + return NewAppsValueUnknown(), diags + } + + apnsTopicHeaderVal, ok := apnsTopicHeaderAttribute.(basetypes.StringValue) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`apns_topic_header expected to be basetypes.StringValue, was: %T`, apnsTopicHeaderAttribute)) + } + + apnsUseSandboxEndpointAttribute, ok := attributes["apns_use_sandbox_endpoint"] + + if !ok { + diags.AddError( + "Attribute Missing", + `apns_use_sandbox_endpoint is missing from object`) + + return NewAppsValueUnknown(), diags + } + + apnsUseSandboxEndpointVal, ok := apnsUseSandboxEndpointAttribute.(basetypes.BoolValue) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`apns_use_sandbox_endpoint expected to be basetypes.BoolValue, was: %T`, apnsUseSandboxEndpointAttribute)) + } + + createdAttribute, ok := attributes["created"] + + if !ok { + diags.AddError( + "Attribute Missing", + `created is missing from object`) + + return NewAppsValueUnknown(), diags + } + + createdVal, ok := createdAttribute.(basetypes.Int64Value) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`created expected to be basetypes.Int64Value, was: %T`, createdAttribute)) + } + + fcmProjectIdAttribute, ok := attributes["fcm_project_id"] + + if !ok { + diags.AddError( + "Attribute Missing", + `fcm_project_id is missing from object`) + + return NewAppsValueUnknown(), diags + } + + fcmProjectIdVal, ok := fcmProjectIdAttribute.(basetypes.StringValue) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`fcm_project_id expected to be basetypes.StringValue, was: %T`, fcmProjectIdAttribute)) + } + + fcmServiceAccountConfiguredAttribute, ok := attributes["fcm_service_account_configured"] + + if !ok { + diags.AddError( + "Attribute Missing", + `fcm_service_account_configured is missing from object`) + + return NewAppsValueUnknown(), diags + } + + fcmServiceAccountConfiguredVal, ok := fcmServiceAccountConfiguredAttribute.(basetypes.BoolValue) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`fcm_service_account_configured expected to be basetypes.BoolValue, was: %T`, fcmServiceAccountConfiguredAttribute)) + } + + idAttribute, ok := attributes["id"] + + if !ok { + diags.AddError( + "Attribute Missing", + `id is missing from object`) + + return NewAppsValueUnknown(), diags + } + + idVal, ok := idAttribute.(basetypes.StringValue) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`id expected to be basetypes.StringValue, was: %T`, idAttribute)) + } + + modifiedAttribute, ok := attributes["modified"] + + if !ok { + diags.AddError( + "Attribute Missing", + `modified is missing from object`) + + return NewAppsValueUnknown(), diags + } + + modifiedVal, ok := modifiedAttribute.(basetypes.Int64Value) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`modified expected to be basetypes.Int64Value, was: %T`, modifiedAttribute)) + } + + nameAttribute, ok := attributes["name"] + + if !ok { + diags.AddError( + "Attribute Missing", + `name is missing from object`) + + return NewAppsValueUnknown(), diags + } + + nameVal, ok := nameAttribute.(basetypes.StringValue) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`name expected to be basetypes.StringValue, was: %T`, nameAttribute)) + } + + statusAttribute, ok := attributes["status"] + + if !ok { + diags.AddError( + "Attribute Missing", + `status is missing from object`) + + return NewAppsValueUnknown(), diags + } + + statusVal, ok := statusAttribute.(basetypes.StringValue) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`status expected to be basetypes.StringValue, was: %T`, statusAttribute)) + } + + tlsOnlyAttribute, ok := attributes["tls_only"] + + if !ok { + diags.AddError( + "Attribute Missing", + `tls_only is missing from object`) + + return NewAppsValueUnknown(), diags + } + + tlsOnlyVal, ok := tlsOnlyAttribute.(basetypes.BoolValue) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`tls_only expected to be basetypes.BoolValue, was: %T`, tlsOnlyAttribute)) + } + + if diags.HasError() { + return NewAppsValueUnknown(), diags + } + + return AppsValue{ + Links: linksVal, + AccountId: accountIdVal, + ApnsAuthType: apnsAuthTypeVal, + ApnsCertificateConfigured: apnsCertificateConfiguredVal, + ApnsIssuerKey: apnsIssuerKeyVal, + ApnsSigningKeyConfigured: apnsSigningKeyConfiguredVal, + ApnsSigningKeyId: apnsSigningKeyIdVal, + ApnsTopicHeader: apnsTopicHeaderVal, + ApnsUseSandboxEndpoint: apnsUseSandboxEndpointVal, + Created: createdVal, + FcmProjectId: fcmProjectIdVal, + FcmServiceAccountConfigured: fcmServiceAccountConfiguredVal, + Id: idVal, + Modified: modifiedVal, + Name: nameVal, + Status: statusVal, + TlsOnly: tlsOnlyVal, + state: attr.ValueStateKnown, + }, diags +} + +func NewAppsValueMust(attributeTypes map[string]attr.Type, attributes map[string]attr.Value) AppsValue { + object, diags := NewAppsValue(attributeTypes, attributes) + + if diags.HasError() { + // This could potentially be added to the diag package. + diagsStrings := make([]string, 0, len(diags)) + + for _, diagnostic := range diags { + diagsStrings = append(diagsStrings, fmt.Sprintf( + "%s | %s | %s", + diagnostic.Severity(), + diagnostic.Summary(), + diagnostic.Detail())) + } + + panic("NewAppsValueMust received error(s): " + strings.Join(diagsStrings, "\n")) + } + + return object +} + +func (t AppsType) ValueFromTerraform(ctx context.Context, in tftypes.Value) (attr.Value, error) { + if in.Type() == nil { + return NewAppsValueNull(), nil + } + + if !in.Type().Equal(t.TerraformType(ctx)) { + return nil, fmt.Errorf("expected %s, got %s", t.TerraformType(ctx), in.Type()) + } + + if !in.IsKnown() { + return NewAppsValueUnknown(), nil + } + + if in.IsNull() { + return NewAppsValueNull(), nil + } + + attributes := map[string]attr.Value{} + + val := map[string]tftypes.Value{} + + err := in.As(&val) + + if err != nil { + return nil, err + } + + for k, v := range val { + a, err := t.AttrTypes[k].ValueFromTerraform(ctx, v) + + if err != nil { + return nil, err + } + + attributes[k] = a + } + + return NewAppsValueMust(AppsValue{}.AttributeTypes(ctx), attributes), nil +} + +func (t AppsType) ValueType(ctx context.Context) attr.Value { + return AppsValue{} +} + +var _ basetypes.ObjectValuable = AppsValue{} + +type AppsValue struct { + Links basetypes.ObjectValue `tfsdk:"_links"` + AccountId basetypes.StringValue `tfsdk:"account_id"` + ApnsAuthType basetypes.StringValue `tfsdk:"apns_auth_type"` + ApnsCertificateConfigured basetypes.BoolValue `tfsdk:"apns_certificate_configured"` + ApnsIssuerKey basetypes.StringValue `tfsdk:"apns_issuer_key"` + ApnsSigningKeyConfigured basetypes.BoolValue `tfsdk:"apns_signing_key_configured"` + ApnsSigningKeyId basetypes.StringValue `tfsdk:"apns_signing_key_id"` + ApnsTopicHeader basetypes.StringValue `tfsdk:"apns_topic_header"` + ApnsUseSandboxEndpoint basetypes.BoolValue `tfsdk:"apns_use_sandbox_endpoint"` + Created basetypes.Int64Value `tfsdk:"created"` + FcmProjectId basetypes.StringValue `tfsdk:"fcm_project_id"` + FcmServiceAccountConfigured basetypes.BoolValue `tfsdk:"fcm_service_account_configured"` + Id basetypes.StringValue `tfsdk:"id"` + Modified basetypes.Int64Value `tfsdk:"modified"` + Name basetypes.StringValue `tfsdk:"name"` + Status basetypes.StringValue `tfsdk:"status"` + TlsOnly basetypes.BoolValue `tfsdk:"tls_only"` + state attr.ValueState +} + +func (v AppsValue) ToTerraformValue(ctx context.Context) (tftypes.Value, error) { + attrTypes := make(map[string]tftypes.Type, 17) + + var val tftypes.Value + var err error + + attrTypes["_links"] = basetypes.ObjectType{ + AttrTypes: LinksValue{}.AttributeTypes(ctx), + }.TerraformType(ctx) + attrTypes["account_id"] = basetypes.StringType{}.TerraformType(ctx) + attrTypes["apns_auth_type"] = basetypes.StringType{}.TerraformType(ctx) + attrTypes["apns_certificate_configured"] = basetypes.BoolType{}.TerraformType(ctx) + attrTypes["apns_issuer_key"] = basetypes.StringType{}.TerraformType(ctx) + attrTypes["apns_signing_key_configured"] = basetypes.BoolType{}.TerraformType(ctx) + attrTypes["apns_signing_key_id"] = basetypes.StringType{}.TerraformType(ctx) + attrTypes["apns_topic_header"] = basetypes.StringType{}.TerraformType(ctx) + attrTypes["apns_use_sandbox_endpoint"] = basetypes.BoolType{}.TerraformType(ctx) + attrTypes["created"] = basetypes.Int64Type{}.TerraformType(ctx) + attrTypes["fcm_project_id"] = basetypes.StringType{}.TerraformType(ctx) + attrTypes["fcm_service_account_configured"] = basetypes.BoolType{}.TerraformType(ctx) + attrTypes["id"] = basetypes.StringType{}.TerraformType(ctx) + attrTypes["modified"] = basetypes.Int64Type{}.TerraformType(ctx) + attrTypes["name"] = basetypes.StringType{}.TerraformType(ctx) + attrTypes["status"] = basetypes.StringType{}.TerraformType(ctx) + attrTypes["tls_only"] = basetypes.BoolType{}.TerraformType(ctx) + + objectType := tftypes.Object{AttributeTypes: attrTypes} + + switch v.state { + case attr.ValueStateKnown: + vals := make(map[string]tftypes.Value, 17) + + val, err = v.Links.ToTerraformValue(ctx) + + if err != nil { + return tftypes.NewValue(objectType, tftypes.UnknownValue), err + } + + vals["_links"] = val + + val, err = v.AccountId.ToTerraformValue(ctx) + + if err != nil { + return tftypes.NewValue(objectType, tftypes.UnknownValue), err + } + + vals["account_id"] = val + + val, err = v.ApnsAuthType.ToTerraformValue(ctx) + + if err != nil { + return tftypes.NewValue(objectType, tftypes.UnknownValue), err + } + + vals["apns_auth_type"] = val + + val, err = v.ApnsCertificateConfigured.ToTerraformValue(ctx) + + if err != nil { + return tftypes.NewValue(objectType, tftypes.UnknownValue), err + } + + vals["apns_certificate_configured"] = val + + val, err = v.ApnsIssuerKey.ToTerraformValue(ctx) + + if err != nil { + return tftypes.NewValue(objectType, tftypes.UnknownValue), err + } + + vals["apns_issuer_key"] = val + + val, err = v.ApnsSigningKeyConfigured.ToTerraformValue(ctx) + + if err != nil { + return tftypes.NewValue(objectType, tftypes.UnknownValue), err + } + + vals["apns_signing_key_configured"] = val + + val, err = v.ApnsSigningKeyId.ToTerraformValue(ctx) + + if err != nil { + return tftypes.NewValue(objectType, tftypes.UnknownValue), err + } + + vals["apns_signing_key_id"] = val + + val, err = v.ApnsTopicHeader.ToTerraformValue(ctx) + + if err != nil { + return tftypes.NewValue(objectType, tftypes.UnknownValue), err + } + + vals["apns_topic_header"] = val + + val, err = v.ApnsUseSandboxEndpoint.ToTerraformValue(ctx) + + if err != nil { + return tftypes.NewValue(objectType, tftypes.UnknownValue), err + } + + vals["apns_use_sandbox_endpoint"] = val + + val, err = v.Created.ToTerraformValue(ctx) + + if err != nil { + return tftypes.NewValue(objectType, tftypes.UnknownValue), err + } + + vals["created"] = val + + val, err = v.FcmProjectId.ToTerraformValue(ctx) + + if err != nil { + return tftypes.NewValue(objectType, tftypes.UnknownValue), err + } + + vals["fcm_project_id"] = val + + val, err = v.FcmServiceAccountConfigured.ToTerraformValue(ctx) + + if err != nil { + return tftypes.NewValue(objectType, tftypes.UnknownValue), err + } + + vals["fcm_service_account_configured"] = val + + val, err = v.Id.ToTerraformValue(ctx) + + if err != nil { + return tftypes.NewValue(objectType, tftypes.UnknownValue), err + } + + vals["id"] = val + + val, err = v.Modified.ToTerraformValue(ctx) + + if err != nil { + return tftypes.NewValue(objectType, tftypes.UnknownValue), err + } + + vals["modified"] = val + + val, err = v.Name.ToTerraformValue(ctx) + + if err != nil { + return tftypes.NewValue(objectType, tftypes.UnknownValue), err + } + + vals["name"] = val + + val, err = v.Status.ToTerraformValue(ctx) + + if err != nil { + return tftypes.NewValue(objectType, tftypes.UnknownValue), err + } + + vals["status"] = val + + val, err = v.TlsOnly.ToTerraformValue(ctx) + + if err != nil { + return tftypes.NewValue(objectType, tftypes.UnknownValue), err + } + + vals["tls_only"] = val + + if err := tftypes.ValidateValue(objectType, vals); err != nil { + return tftypes.NewValue(objectType, tftypes.UnknownValue), err + } + + return tftypes.NewValue(objectType, vals), nil + case attr.ValueStateNull: + return tftypes.NewValue(objectType, nil), nil + case attr.ValueStateUnknown: + return tftypes.NewValue(objectType, tftypes.UnknownValue), nil + default: + panic(fmt.Sprintf("unhandled Object state in ToTerraformValue: %s", v.state)) + } +} + +func (v AppsValue) IsNull() bool { + return v.state == attr.ValueStateNull +} + +func (v AppsValue) IsUnknown() bool { + return v.state == attr.ValueStateUnknown +} + +func (v AppsValue) String() string { + return "AppsValue" +} + +func (v AppsValue) ToObjectValue(ctx context.Context) (basetypes.ObjectValue, diag.Diagnostics) { + var diags diag.Diagnostics + + var links basetypes.ObjectValue + + if v.Links.IsNull() { + links = types.ObjectNull( + LinksValue{}.AttributeTypes(ctx), + ) + } + + if v.Links.IsUnknown() { + links = types.ObjectUnknown( + LinksValue{}.AttributeTypes(ctx), + ) + } + + if !v.Links.IsNull() && !v.Links.IsUnknown() { + links = types.ObjectValueMust( + LinksValue{}.AttributeTypes(ctx), + v.Links.Attributes(), + ) + } + + attributeTypes := map[string]attr.Type{ + "_links": basetypes.ObjectType{ + AttrTypes: LinksValue{}.AttributeTypes(ctx), + }, + "account_id": basetypes.StringType{}, + "apns_auth_type": basetypes.StringType{}, + "apns_certificate_configured": basetypes.BoolType{}, + "apns_issuer_key": basetypes.StringType{}, + "apns_signing_key_configured": basetypes.BoolType{}, + "apns_signing_key_id": basetypes.StringType{}, + "apns_topic_header": basetypes.StringType{}, + "apns_use_sandbox_endpoint": basetypes.BoolType{}, + "created": basetypes.Int64Type{}, + "fcm_project_id": basetypes.StringType{}, + "fcm_service_account_configured": basetypes.BoolType{}, + "id": basetypes.StringType{}, + "modified": basetypes.Int64Type{}, + "name": basetypes.StringType{}, + "status": basetypes.StringType{}, + "tls_only": basetypes.BoolType{}, + } + + if v.IsNull() { + return types.ObjectNull(attributeTypes), diags + } + + if v.IsUnknown() { + return types.ObjectUnknown(attributeTypes), diags + } + + objVal, diags := types.ObjectValue( + attributeTypes, + map[string]attr.Value{ + "_links": links, + "account_id": v.AccountId, + "apns_auth_type": v.ApnsAuthType, + "apns_certificate_configured": v.ApnsCertificateConfigured, + "apns_issuer_key": v.ApnsIssuerKey, + "apns_signing_key_configured": v.ApnsSigningKeyConfigured, + "apns_signing_key_id": v.ApnsSigningKeyId, + "apns_topic_header": v.ApnsTopicHeader, + "apns_use_sandbox_endpoint": v.ApnsUseSandboxEndpoint, + "created": v.Created, + "fcm_project_id": v.FcmProjectId, + "fcm_service_account_configured": v.FcmServiceAccountConfigured, + "id": v.Id, + "modified": v.Modified, + "name": v.Name, + "status": v.Status, + "tls_only": v.TlsOnly, + }) + + return objVal, diags +} + +func (v AppsValue) Equal(o attr.Value) bool { + other, ok := o.(AppsValue) + + if !ok { + return false + } + + if v.state != other.state { + return false + } + + if v.state != attr.ValueStateKnown { + return true + } + + if !v.Links.Equal(other.Links) { + return false + } + + if !v.AccountId.Equal(other.AccountId) { + return false + } + + if !v.ApnsAuthType.Equal(other.ApnsAuthType) { + return false + } + + if !v.ApnsCertificateConfigured.Equal(other.ApnsCertificateConfigured) { + return false + } + + if !v.ApnsIssuerKey.Equal(other.ApnsIssuerKey) { + return false + } + + if !v.ApnsSigningKeyConfigured.Equal(other.ApnsSigningKeyConfigured) { + return false + } + + if !v.ApnsSigningKeyId.Equal(other.ApnsSigningKeyId) { + return false + } + + if !v.ApnsTopicHeader.Equal(other.ApnsTopicHeader) { + return false + } + + if !v.ApnsUseSandboxEndpoint.Equal(other.ApnsUseSandboxEndpoint) { + return false + } + + if !v.Created.Equal(other.Created) { + return false + } + + if !v.FcmProjectId.Equal(other.FcmProjectId) { + return false + } + + if !v.FcmServiceAccountConfigured.Equal(other.FcmServiceAccountConfigured) { + return false + } + + if !v.Id.Equal(other.Id) { + return false + } + + if !v.Modified.Equal(other.Modified) { + return false + } + + if !v.Name.Equal(other.Name) { + return false + } + + if !v.Status.Equal(other.Status) { + return false + } + + if !v.TlsOnly.Equal(other.TlsOnly) { + return false + } + + return true +} + +func (v AppsValue) Type(ctx context.Context) attr.Type { + return AppsType{ + basetypes.ObjectType{ + AttrTypes: v.AttributeTypes(ctx), + }, + } +} + +func (v AppsValue) AttributeTypes(ctx context.Context) map[string]attr.Type { + return map[string]attr.Type{ + "_links": basetypes.ObjectType{ + AttrTypes: LinksValue{}.AttributeTypes(ctx), + }, + "account_id": basetypes.StringType{}, + "apns_auth_type": basetypes.StringType{}, + "apns_certificate_configured": basetypes.BoolType{}, + "apns_issuer_key": basetypes.StringType{}, + "apns_signing_key_configured": basetypes.BoolType{}, + "apns_signing_key_id": basetypes.StringType{}, + "apns_topic_header": basetypes.StringType{}, + "apns_use_sandbox_endpoint": basetypes.BoolType{}, + "created": basetypes.Int64Type{}, + "fcm_project_id": basetypes.StringType{}, + "fcm_service_account_configured": basetypes.BoolType{}, + "id": basetypes.StringType{}, + "modified": basetypes.Int64Type{}, + "name": basetypes.StringType{}, + "status": basetypes.StringType{}, + "tls_only": basetypes.BoolType{}, + } +} + +var _ basetypes.ObjectTypable = LinksType{} + +type LinksType struct { + basetypes.ObjectType +} + +func (t LinksType) Equal(o attr.Type) bool { + other, ok := o.(LinksType) + + if !ok { + return false + } + + return t.ObjectType.Equal(other.ObjectType) +} + +func (t LinksType) String() string { + return "LinksType" +} + +func (t LinksType) ValueFromObject(ctx context.Context, in basetypes.ObjectValue) (basetypes.ObjectValuable, diag.Diagnostics) { + var diags diag.Diagnostics + + if diags.HasError() { + return nil, diags + } + + return LinksValue{ + state: attr.ValueStateKnown, + }, diags +} + +func NewLinksValueNull() LinksValue { + return LinksValue{ + state: attr.ValueStateNull, + } +} + +func NewLinksValueUnknown() LinksValue { + return LinksValue{ + state: attr.ValueStateUnknown, + } +} + +func NewLinksValue(attributeTypes map[string]attr.Type, attributes map[string]attr.Value) (LinksValue, diag.Diagnostics) { + var diags diag.Diagnostics + + // Reference: https://github.com/hashicorp/terraform-plugin-framework/issues/521 + ctx := context.Background() + + for name, attributeType := range attributeTypes { + attribute, ok := attributes[name] + + if !ok { + diags.AddError( + "Missing LinksValue Attribute Value", + "While creating a LinksValue value, a missing attribute value was detected. "+ + "A LinksValue must contain values for all attributes, even if null or unknown. "+ + "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ + fmt.Sprintf("LinksValue Attribute Name (%s) Expected Type: %s", name, attributeType.String()), + ) + + continue + } + + if !attributeType.Equal(attribute.Type(ctx)) { + diags.AddError( + "Invalid LinksValue Attribute Type", + "While creating a LinksValue value, an invalid attribute value was detected. "+ + "A LinksValue must use a matching attribute type for the value. "+ + "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ + fmt.Sprintf("LinksValue Attribute Name (%s) Expected Type: %s\n", name, attributeType.String())+ + fmt.Sprintf("LinksValue Attribute Name (%s) Given Type: %s", name, attribute.Type(ctx)), + ) + } + } + + for name := range attributes { + _, ok := attributeTypes[name] + + if !ok { + diags.AddError( + "Extra LinksValue Attribute Value", + "While creating a LinksValue value, an extra attribute value was detected. "+ + "A LinksValue must not contain values beyond the expected attribute types. "+ + "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ + fmt.Sprintf("Extra LinksValue Attribute Name: %s", name), + ) + } + } + + if diags.HasError() { + return NewLinksValueUnknown(), diags + } + + if diags.HasError() { + return NewLinksValueUnknown(), diags + } + + return LinksValue{ + state: attr.ValueStateKnown, + }, diags +} + +func NewLinksValueMust(attributeTypes map[string]attr.Type, attributes map[string]attr.Value) LinksValue { + object, diags := NewLinksValue(attributeTypes, attributes) + + if diags.HasError() { + // This could potentially be added to the diag package. + diagsStrings := make([]string, 0, len(diags)) + + for _, diagnostic := range diags { + diagsStrings = append(diagsStrings, fmt.Sprintf( + "%s | %s | %s", + diagnostic.Severity(), + diagnostic.Summary(), + diagnostic.Detail())) + } + + panic("NewLinksValueMust received error(s): " + strings.Join(diagsStrings, "\n")) + } + + return object +} + +func (t LinksType) ValueFromTerraform(ctx context.Context, in tftypes.Value) (attr.Value, error) { + if in.Type() == nil { + return NewLinksValueNull(), nil + } + + if !in.Type().Equal(t.TerraformType(ctx)) { + return nil, fmt.Errorf("expected %s, got %s", t.TerraformType(ctx), in.Type()) + } + + if !in.IsKnown() { + return NewLinksValueUnknown(), nil + } + + if in.IsNull() { + return NewLinksValueNull(), nil + } + + attributes := map[string]attr.Value{} + + val := map[string]tftypes.Value{} + + err := in.As(&val) + + if err != nil { + return nil, err + } + + for k, v := range val { + a, err := t.AttrTypes[k].ValueFromTerraform(ctx, v) + + if err != nil { + return nil, err + } + + attributes[k] = a + } + + return NewLinksValueMust(LinksValue{}.AttributeTypes(ctx), attributes), nil +} + +func (t LinksType) ValueType(ctx context.Context) attr.Value { + return LinksValue{} +} + +var _ basetypes.ObjectValuable = LinksValue{} + +type LinksValue struct { + state attr.ValueState +} + +func (v LinksValue) ToTerraformValue(ctx context.Context) (tftypes.Value, error) { + attrTypes := make(map[string]tftypes.Type, 0) + + objectType := tftypes.Object{AttributeTypes: attrTypes} + + switch v.state { + case attr.ValueStateKnown: + vals := make(map[string]tftypes.Value, 0) + + if err := tftypes.ValidateValue(objectType, vals); err != nil { + return tftypes.NewValue(objectType, tftypes.UnknownValue), err + } + + return tftypes.NewValue(objectType, vals), nil + case attr.ValueStateNull: + return tftypes.NewValue(objectType, nil), nil + case attr.ValueStateUnknown: + return tftypes.NewValue(objectType, tftypes.UnknownValue), nil + default: + panic(fmt.Sprintf("unhandled Object state in ToTerraformValue: %s", v.state)) + } +} + +func (v LinksValue) IsNull() bool { + return v.state == attr.ValueStateNull +} + +func (v LinksValue) IsUnknown() bool { + return v.state == attr.ValueStateUnknown +} + +func (v LinksValue) String() string { + return "LinksValue" +} + +func (v LinksValue) ToObjectValue(ctx context.Context) (basetypes.ObjectValue, diag.Diagnostics) { + var diags diag.Diagnostics + + attributeTypes := map[string]attr.Type{} + + if v.IsNull() { + return types.ObjectNull(attributeTypes), diags + } + + if v.IsUnknown() { + return types.ObjectUnknown(attributeTypes), diags + } + + objVal, diags := types.ObjectValue( + attributeTypes, + map[string]attr.Value{}) + + return objVal, diags +} + +func (v LinksValue) Equal(o attr.Value) bool { + other, ok := o.(LinksValue) + + if !ok { + return false + } + + if v.state != other.state { + return false + } + + if v.state != attr.ValueStateKnown { + return true + } + + return true +} + +func (v LinksValue) Type(ctx context.Context) attr.Type { + return LinksType{ + basetypes.ObjectType{ + AttrTypes: v.AttributeTypes(ctx), + }, + } +} + +func (v LinksValue) AttributeTypes(ctx context.Context) map[string]attr.Type { + return map[string]attr.Type{} +} diff --git a/internal/provider/codegen/datasource_keys/keys_data_source_gen.go b/internal/provider/codegen/datasource_keys/keys_data_source_gen.go new file mode 100644 index 0000000..b69cb43 --- /dev/null +++ b/internal/provider/codegen/datasource_keys/keys_data_source_gen.go @@ -0,0 +1,906 @@ +// Code generated by terraform-plugin-framework-generator DO NOT EDIT. + +package datasource_keys + +import ( + "context" + "fmt" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" + "github.com/hashicorp/terraform-plugin-go/tftypes" + "strings" + + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" +) + +func KeysDataSourceSchema(ctx context.Context) schema.Schema { + return schema.Schema{ + Attributes: map[string]schema.Attribute{ + "app_id": schema.StringAttribute{ + Required: true, + Description: "The application ID.", + MarkdownDescription: "The application ID.", + }, + "keys": schema.SetNestedAttribute{ + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "app_id": schema.StringAttribute{ + Computed: true, + Description: "The Ably application ID which this key is associated with.", + MarkdownDescription: "The Ably application ID which this key is associated with.", + }, + "capability": schema.MapAttribute{ + ElementType: types.ListType{ + ElemType: types.StringType, + }, + Computed: true, + Description: "The capabilities that this key has. More information on capabilities can be found in the Ably capabilities docs.", + MarkdownDescription: "The capabilities that this key has. More information on capabilities can be found in the Ably capabilities docs.", + }, + "created": schema.Int64Attribute{ + Computed: true, + Description: "Unix timestamp representing the date and time of creation of the key.", + MarkdownDescription: "Unix timestamp representing the date and time of creation of the key.", + }, + "id": schema.StringAttribute{ + Computed: true, + Description: "The key ID.", + MarkdownDescription: "The key ID.", + }, + "key": schema.StringAttribute{ + Computed: true, + Sensitive: true, + Description: "The complete API key including API secret.", + MarkdownDescription: "The complete API key including API secret.", + }, + "modified": schema.Int64Attribute{ + Computed: true, + Description: "Unix timestamp representing the date and time of the last modification of the key.", + MarkdownDescription: "Unix timestamp representing the date and time of the last modification of the key.", + }, + "name": schema.StringAttribute{ + Computed: true, + Description: "The name for your API key. This is a friendly name for your reference.", + MarkdownDescription: "The name for your API key. This is a friendly name for your reference.", + }, + "revocable_tokens": schema.BoolAttribute{ + Computed: true, + Description: "If `true`, tokens issued by this key can be individually revoked. See the token revocation documentation for more details.", + MarkdownDescription: "If `true`, tokens issued by this key can be individually revoked. See the token revocation documentation for more details.", + }, + "status": schema.Int64Attribute{ + Computed: true, + Description: "The status of the key. `0` is enabled, `1` is revoked.", + MarkdownDescription: "The status of the key. `0` is enabled, `1` is revoked.", + }, + }, + CustomType: KeysType{ + ObjectType: types.ObjectType{ + AttrTypes: KeysValue{}.AttributeTypes(ctx), + }, + }, + }, + Computed: true, + }, + }, + } +} + +type KeysModel struct { + AppId types.String `tfsdk:"app_id"` + Keys types.Set `tfsdk:"keys"` +} + +var _ basetypes.ObjectTypable = KeysType{} + +type KeysType struct { + basetypes.ObjectType +} + +func (t KeysType) Equal(o attr.Type) bool { + other, ok := o.(KeysType) + + if !ok { + return false + } + + return t.ObjectType.Equal(other.ObjectType) +} + +func (t KeysType) String() string { + return "KeysType" +} + +func (t KeysType) ValueFromObject(ctx context.Context, in basetypes.ObjectValue) (basetypes.ObjectValuable, diag.Diagnostics) { + var diags diag.Diagnostics + + attributes := in.Attributes() + + appIdAttribute, ok := attributes["app_id"] + + if !ok { + diags.AddError( + "Attribute Missing", + `app_id is missing from object`) + + return nil, diags + } + + appIdVal, ok := appIdAttribute.(basetypes.StringValue) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`app_id expected to be basetypes.StringValue, was: %T`, appIdAttribute)) + } + + capabilityAttribute, ok := attributes["capability"] + + if !ok { + diags.AddError( + "Attribute Missing", + `capability is missing from object`) + + return nil, diags + } + + capabilityVal, ok := capabilityAttribute.(basetypes.MapValue) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`capability expected to be basetypes.MapValue, was: %T`, capabilityAttribute)) + } + + createdAttribute, ok := attributes["created"] + + if !ok { + diags.AddError( + "Attribute Missing", + `created is missing from object`) + + return nil, diags + } + + createdVal, ok := createdAttribute.(basetypes.Int64Value) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`created expected to be basetypes.Int64Value, was: %T`, createdAttribute)) + } + + idAttribute, ok := attributes["id"] + + if !ok { + diags.AddError( + "Attribute Missing", + `id is missing from object`) + + return nil, diags + } + + idVal, ok := idAttribute.(basetypes.StringValue) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`id expected to be basetypes.StringValue, was: %T`, idAttribute)) + } + + keyAttribute, ok := attributes["key"] + + if !ok { + diags.AddError( + "Attribute Missing", + `key is missing from object`) + + return nil, diags + } + + keyVal, ok := keyAttribute.(basetypes.StringValue) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`key expected to be basetypes.StringValue, was: %T`, keyAttribute)) + } + + modifiedAttribute, ok := attributes["modified"] + + if !ok { + diags.AddError( + "Attribute Missing", + `modified is missing from object`) + + return nil, diags + } + + modifiedVal, ok := modifiedAttribute.(basetypes.Int64Value) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`modified expected to be basetypes.Int64Value, was: %T`, modifiedAttribute)) + } + + nameAttribute, ok := attributes["name"] + + if !ok { + diags.AddError( + "Attribute Missing", + `name is missing from object`) + + return nil, diags + } + + nameVal, ok := nameAttribute.(basetypes.StringValue) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`name expected to be basetypes.StringValue, was: %T`, nameAttribute)) + } + + revocableTokensAttribute, ok := attributes["revocable_tokens"] + + if !ok { + diags.AddError( + "Attribute Missing", + `revocable_tokens is missing from object`) + + return nil, diags + } + + revocableTokensVal, ok := revocableTokensAttribute.(basetypes.BoolValue) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`revocable_tokens expected to be basetypes.BoolValue, was: %T`, revocableTokensAttribute)) + } + + statusAttribute, ok := attributes["status"] + + if !ok { + diags.AddError( + "Attribute Missing", + `status is missing from object`) + + return nil, diags + } + + statusVal, ok := statusAttribute.(basetypes.Int64Value) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`status expected to be basetypes.Int64Value, was: %T`, statusAttribute)) + } + + if diags.HasError() { + return nil, diags + } + + return KeysValue{ + AppId: appIdVal, + Capability: capabilityVal, + Created: createdVal, + Id: idVal, + Key: keyVal, + Modified: modifiedVal, + Name: nameVal, + RevocableTokens: revocableTokensVal, + Status: statusVal, + state: attr.ValueStateKnown, + }, diags +} + +func NewKeysValueNull() KeysValue { + return KeysValue{ + state: attr.ValueStateNull, + } +} + +func NewKeysValueUnknown() KeysValue { + return KeysValue{ + state: attr.ValueStateUnknown, + } +} + +func NewKeysValue(attributeTypes map[string]attr.Type, attributes map[string]attr.Value) (KeysValue, diag.Diagnostics) { + var diags diag.Diagnostics + + // Reference: https://github.com/hashicorp/terraform-plugin-framework/issues/521 + ctx := context.Background() + + for name, attributeType := range attributeTypes { + attribute, ok := attributes[name] + + if !ok { + diags.AddError( + "Missing KeysValue Attribute Value", + "While creating a KeysValue value, a missing attribute value was detected. "+ + "A KeysValue must contain values for all attributes, even if null or unknown. "+ + "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ + fmt.Sprintf("KeysValue Attribute Name (%s) Expected Type: %s", name, attributeType.String()), + ) + + continue + } + + if !attributeType.Equal(attribute.Type(ctx)) { + diags.AddError( + "Invalid KeysValue Attribute Type", + "While creating a KeysValue value, an invalid attribute value was detected. "+ + "A KeysValue must use a matching attribute type for the value. "+ + "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ + fmt.Sprintf("KeysValue Attribute Name (%s) Expected Type: %s\n", name, attributeType.String())+ + fmt.Sprintf("KeysValue Attribute Name (%s) Given Type: %s", name, attribute.Type(ctx)), + ) + } + } + + for name := range attributes { + _, ok := attributeTypes[name] + + if !ok { + diags.AddError( + "Extra KeysValue Attribute Value", + "While creating a KeysValue value, an extra attribute value was detected. "+ + "A KeysValue must not contain values beyond the expected attribute types. "+ + "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ + fmt.Sprintf("Extra KeysValue Attribute Name: %s", name), + ) + } + } + + if diags.HasError() { + return NewKeysValueUnknown(), diags + } + + appIdAttribute, ok := attributes["app_id"] + + if !ok { + diags.AddError( + "Attribute Missing", + `app_id is missing from object`) + + return NewKeysValueUnknown(), diags + } + + appIdVal, ok := appIdAttribute.(basetypes.StringValue) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`app_id expected to be basetypes.StringValue, was: %T`, appIdAttribute)) + } + + capabilityAttribute, ok := attributes["capability"] + + if !ok { + diags.AddError( + "Attribute Missing", + `capability is missing from object`) + + return NewKeysValueUnknown(), diags + } + + capabilityVal, ok := capabilityAttribute.(basetypes.MapValue) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`capability expected to be basetypes.MapValue, was: %T`, capabilityAttribute)) + } + + createdAttribute, ok := attributes["created"] + + if !ok { + diags.AddError( + "Attribute Missing", + `created is missing from object`) + + return NewKeysValueUnknown(), diags + } + + createdVal, ok := createdAttribute.(basetypes.Int64Value) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`created expected to be basetypes.Int64Value, was: %T`, createdAttribute)) + } + + idAttribute, ok := attributes["id"] + + if !ok { + diags.AddError( + "Attribute Missing", + `id is missing from object`) + + return NewKeysValueUnknown(), diags + } + + idVal, ok := idAttribute.(basetypes.StringValue) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`id expected to be basetypes.StringValue, was: %T`, idAttribute)) + } + + keyAttribute, ok := attributes["key"] + + if !ok { + diags.AddError( + "Attribute Missing", + `key is missing from object`) + + return NewKeysValueUnknown(), diags + } + + keyVal, ok := keyAttribute.(basetypes.StringValue) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`key expected to be basetypes.StringValue, was: %T`, keyAttribute)) + } + + modifiedAttribute, ok := attributes["modified"] + + if !ok { + diags.AddError( + "Attribute Missing", + `modified is missing from object`) + + return NewKeysValueUnknown(), diags + } + + modifiedVal, ok := modifiedAttribute.(basetypes.Int64Value) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`modified expected to be basetypes.Int64Value, was: %T`, modifiedAttribute)) + } + + nameAttribute, ok := attributes["name"] + + if !ok { + diags.AddError( + "Attribute Missing", + `name is missing from object`) + + return NewKeysValueUnknown(), diags + } + + nameVal, ok := nameAttribute.(basetypes.StringValue) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`name expected to be basetypes.StringValue, was: %T`, nameAttribute)) + } + + revocableTokensAttribute, ok := attributes["revocable_tokens"] + + if !ok { + diags.AddError( + "Attribute Missing", + `revocable_tokens is missing from object`) + + return NewKeysValueUnknown(), diags + } + + revocableTokensVal, ok := revocableTokensAttribute.(basetypes.BoolValue) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`revocable_tokens expected to be basetypes.BoolValue, was: %T`, revocableTokensAttribute)) + } + + statusAttribute, ok := attributes["status"] + + if !ok { + diags.AddError( + "Attribute Missing", + `status is missing from object`) + + return NewKeysValueUnknown(), diags + } + + statusVal, ok := statusAttribute.(basetypes.Int64Value) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`status expected to be basetypes.Int64Value, was: %T`, statusAttribute)) + } + + if diags.HasError() { + return NewKeysValueUnknown(), diags + } + + return KeysValue{ + AppId: appIdVal, + Capability: capabilityVal, + Created: createdVal, + Id: idVal, + Key: keyVal, + Modified: modifiedVal, + Name: nameVal, + RevocableTokens: revocableTokensVal, + Status: statusVal, + state: attr.ValueStateKnown, + }, diags +} + +func NewKeysValueMust(attributeTypes map[string]attr.Type, attributes map[string]attr.Value) KeysValue { + object, diags := NewKeysValue(attributeTypes, attributes) + + if diags.HasError() { + // This could potentially be added to the diag package. + diagsStrings := make([]string, 0, len(diags)) + + for _, diagnostic := range diags { + diagsStrings = append(diagsStrings, fmt.Sprintf( + "%s | %s | %s", + diagnostic.Severity(), + diagnostic.Summary(), + diagnostic.Detail())) + } + + panic("NewKeysValueMust received error(s): " + strings.Join(diagsStrings, "\n")) + } + + return object +} + +func (t KeysType) ValueFromTerraform(ctx context.Context, in tftypes.Value) (attr.Value, error) { + if in.Type() == nil { + return NewKeysValueNull(), nil + } + + if !in.Type().Equal(t.TerraformType(ctx)) { + return nil, fmt.Errorf("expected %s, got %s", t.TerraformType(ctx), in.Type()) + } + + if !in.IsKnown() { + return NewKeysValueUnknown(), nil + } + + if in.IsNull() { + return NewKeysValueNull(), nil + } + + attributes := map[string]attr.Value{} + + val := map[string]tftypes.Value{} + + err := in.As(&val) + + if err != nil { + return nil, err + } + + for k, v := range val { + a, err := t.AttrTypes[k].ValueFromTerraform(ctx, v) + + if err != nil { + return nil, err + } + + attributes[k] = a + } + + return NewKeysValueMust(KeysValue{}.AttributeTypes(ctx), attributes), nil +} + +func (t KeysType) ValueType(ctx context.Context) attr.Value { + return KeysValue{} +} + +var _ basetypes.ObjectValuable = KeysValue{} + +type KeysValue struct { + AppId basetypes.StringValue `tfsdk:"app_id"` + Capability basetypes.MapValue `tfsdk:"capability"` + Created basetypes.Int64Value `tfsdk:"created"` + Id basetypes.StringValue `tfsdk:"id"` + Key basetypes.StringValue `tfsdk:"key"` + Modified basetypes.Int64Value `tfsdk:"modified"` + Name basetypes.StringValue `tfsdk:"name"` + RevocableTokens basetypes.BoolValue `tfsdk:"revocable_tokens"` + Status basetypes.Int64Value `tfsdk:"status"` + state attr.ValueState +} + +func (v KeysValue) ToTerraformValue(ctx context.Context) (tftypes.Value, error) { + attrTypes := make(map[string]tftypes.Type, 9) + + var val tftypes.Value + var err error + + attrTypes["app_id"] = basetypes.StringType{}.TerraformType(ctx) + attrTypes["capability"] = basetypes.MapType{ + ElemType: types.ListType{ + ElemType: types.StringType, + }, + }.TerraformType(ctx) + attrTypes["created"] = basetypes.Int64Type{}.TerraformType(ctx) + attrTypes["id"] = basetypes.StringType{}.TerraformType(ctx) + attrTypes["key"] = basetypes.StringType{}.TerraformType(ctx) + attrTypes["modified"] = basetypes.Int64Type{}.TerraformType(ctx) + attrTypes["name"] = basetypes.StringType{}.TerraformType(ctx) + attrTypes["revocable_tokens"] = basetypes.BoolType{}.TerraformType(ctx) + attrTypes["status"] = basetypes.Int64Type{}.TerraformType(ctx) + + objectType := tftypes.Object{AttributeTypes: attrTypes} + + switch v.state { + case attr.ValueStateKnown: + vals := make(map[string]tftypes.Value, 9) + + val, err = v.AppId.ToTerraformValue(ctx) + + if err != nil { + return tftypes.NewValue(objectType, tftypes.UnknownValue), err + } + + vals["app_id"] = val + + val, err = v.Capability.ToTerraformValue(ctx) + + if err != nil { + return tftypes.NewValue(objectType, tftypes.UnknownValue), err + } + + vals["capability"] = val + + val, err = v.Created.ToTerraformValue(ctx) + + if err != nil { + return tftypes.NewValue(objectType, tftypes.UnknownValue), err + } + + vals["created"] = val + + val, err = v.Id.ToTerraformValue(ctx) + + if err != nil { + return tftypes.NewValue(objectType, tftypes.UnknownValue), err + } + + vals["id"] = val + + val, err = v.Key.ToTerraformValue(ctx) + + if err != nil { + return tftypes.NewValue(objectType, tftypes.UnknownValue), err + } + + vals["key"] = val + + val, err = v.Modified.ToTerraformValue(ctx) + + if err != nil { + return tftypes.NewValue(objectType, tftypes.UnknownValue), err + } + + vals["modified"] = val + + val, err = v.Name.ToTerraformValue(ctx) + + if err != nil { + return tftypes.NewValue(objectType, tftypes.UnknownValue), err + } + + vals["name"] = val + + val, err = v.RevocableTokens.ToTerraformValue(ctx) + + if err != nil { + return tftypes.NewValue(objectType, tftypes.UnknownValue), err + } + + vals["revocable_tokens"] = val + + val, err = v.Status.ToTerraformValue(ctx) + + if err != nil { + return tftypes.NewValue(objectType, tftypes.UnknownValue), err + } + + vals["status"] = val + + if err := tftypes.ValidateValue(objectType, vals); err != nil { + return tftypes.NewValue(objectType, tftypes.UnknownValue), err + } + + return tftypes.NewValue(objectType, vals), nil + case attr.ValueStateNull: + return tftypes.NewValue(objectType, nil), nil + case attr.ValueStateUnknown: + return tftypes.NewValue(objectType, tftypes.UnknownValue), nil + default: + panic(fmt.Sprintf("unhandled Object state in ToTerraformValue: %s", v.state)) + } +} + +func (v KeysValue) IsNull() bool { + return v.state == attr.ValueStateNull +} + +func (v KeysValue) IsUnknown() bool { + return v.state == attr.ValueStateUnknown +} + +func (v KeysValue) String() string { + return "KeysValue" +} + +func (v KeysValue) ToObjectValue(ctx context.Context) (basetypes.ObjectValue, diag.Diagnostics) { + var diags diag.Diagnostics + + var capabilityVal basetypes.MapValue + switch { + case v.Capability.IsUnknown(): + capabilityVal = types.MapUnknown(types.ListType{ + ElemType: types.StringType, + }) + case v.Capability.IsNull(): + capabilityVal = types.MapNull(types.ListType{ + ElemType: types.StringType, + }) + default: + var d diag.Diagnostics + capabilityVal, d = types.MapValue(types.ListType{ + ElemType: types.StringType, + }, v.Capability.Elements()) + diags.Append(d...) + } + + if diags.HasError() { + return types.ObjectUnknown(map[string]attr.Type{ + "app_id": basetypes.StringType{}, + "capability": basetypes.MapType{ + ElemType: types.ListType{ + ElemType: types.StringType, + }, + }, + "created": basetypes.Int64Type{}, + "id": basetypes.StringType{}, + "key": basetypes.StringType{}, + "modified": basetypes.Int64Type{}, + "name": basetypes.StringType{}, + "revocable_tokens": basetypes.BoolType{}, + "status": basetypes.Int64Type{}, + }), diags + } + + attributeTypes := map[string]attr.Type{ + "app_id": basetypes.StringType{}, + "capability": basetypes.MapType{ + ElemType: types.ListType{ + ElemType: types.StringType, + }, + }, + "created": basetypes.Int64Type{}, + "id": basetypes.StringType{}, + "key": basetypes.StringType{}, + "modified": basetypes.Int64Type{}, + "name": basetypes.StringType{}, + "revocable_tokens": basetypes.BoolType{}, + "status": basetypes.Int64Type{}, + } + + if v.IsNull() { + return types.ObjectNull(attributeTypes), diags + } + + if v.IsUnknown() { + return types.ObjectUnknown(attributeTypes), diags + } + + objVal, diags := types.ObjectValue( + attributeTypes, + map[string]attr.Value{ + "app_id": v.AppId, + "capability": capabilityVal, + "created": v.Created, + "id": v.Id, + "key": v.Key, + "modified": v.Modified, + "name": v.Name, + "revocable_tokens": v.RevocableTokens, + "status": v.Status, + }) + + return objVal, diags +} + +func (v KeysValue) Equal(o attr.Value) bool { + other, ok := o.(KeysValue) + + if !ok { + return false + } + + if v.state != other.state { + return false + } + + if v.state != attr.ValueStateKnown { + return true + } + + if !v.AppId.Equal(other.AppId) { + return false + } + + if !v.Capability.Equal(other.Capability) { + return false + } + + if !v.Created.Equal(other.Created) { + return false + } + + if !v.Id.Equal(other.Id) { + return false + } + + if !v.Key.Equal(other.Key) { + return false + } + + if !v.Modified.Equal(other.Modified) { + return false + } + + if !v.Name.Equal(other.Name) { + return false + } + + if !v.RevocableTokens.Equal(other.RevocableTokens) { + return false + } + + if !v.Status.Equal(other.Status) { + return false + } + + return true +} + +func (v KeysValue) Type(ctx context.Context) attr.Type { + return KeysType{ + basetypes.ObjectType{ + AttrTypes: v.AttributeTypes(ctx), + }, + } +} + +func (v KeysValue) AttributeTypes(ctx context.Context) map[string]attr.Type { + return map[string]attr.Type{ + "app_id": basetypes.StringType{}, + "capability": basetypes.MapType{ + ElemType: types.ListType{ + ElemType: types.StringType, + }, + }, + "created": basetypes.Int64Type{}, + "id": basetypes.StringType{}, + "key": basetypes.StringType{}, + "modified": basetypes.Int64Type{}, + "name": basetypes.StringType{}, + "revocable_tokens": basetypes.BoolType{}, + "status": basetypes.Int64Type{}, + } +} diff --git a/internal/provider/codegen/datasource_me/me_data_source_gen.go b/internal/provider/codegen/datasource_me/me_data_source_gen.go new file mode 100644 index 0000000..c77cd6e --- /dev/null +++ b/internal/provider/codegen/datasource_me/me_data_source_gen.go @@ -0,0 +1,1437 @@ +// Code generated by terraform-plugin-framework-generator DO NOT EDIT. + +package datasource_me + +import ( + "context" + "fmt" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" + "github.com/hashicorp/terraform-plugin-go/tftypes" + "strings" + + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" +) + +func MeDataSourceSchema(ctx context.Context) schema.Schema { + return schema.Schema{ + Attributes: map[string]schema.Attribute{ + "account": schema.SingleNestedAttribute{ + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Computed: true, + Description: "The account ID.", + MarkdownDescription: "The account ID.", + }, + "name": schema.StringAttribute{ + Computed: true, + Description: "The name of the account.", + MarkdownDescription: "The name of the account.", + }, + }, + CustomType: AccountType{ + ObjectType: types.ObjectType{ + AttrTypes: AccountValue{}.AttributeTypes(ctx), + }, + }, + Computed: true, + }, + "token": schema.SingleNestedAttribute{ + Attributes: map[string]schema.Attribute{ + "capabilities": schema.ListAttribute{ + ElementType: types.StringType, + Computed: true, + Description: "An array containing the access capabilities associated with the access token.", + MarkdownDescription: "An array containing the access capabilities associated with the access token.", + }, + "expires_at": schema.StringAttribute{ + Computed: true, + Description: "The date and time at which the access token expires, in ISO 8601 format. A value of `null` indicates the token does not expire.", + MarkdownDescription: "The date and time at which the access token expires, in ISO 8601 format. A value of `null` indicates the token does not expire.", + }, + "id": schema.StringAttribute{ + Computed: true, + Description: "The token ID.", + MarkdownDescription: "The token ID.", + }, + "last_used_at": schema.StringAttribute{ + Computed: true, + Description: "The date and time at which the access token was last used to authenticate a Control API request, in ISO 8601 format. Updated at most once per 10-minute window. A value of `null` indicates the token has not been used since tracking began.", + MarkdownDescription: "The date and time at which the access token was last used to authenticate a Control API request, in ISO 8601 format. Updated at most once per 10-minute window. A value of `null` indicates the token has not been used since tracking began.", + }, + "name": schema.StringAttribute{ + Computed: true, + Description: "The friendly name for the token.", + MarkdownDescription: "The friendly name for the token.", + }, + }, + CustomType: TokenType{ + ObjectType: types.ObjectType{ + AttrTypes: TokenValue{}.AttributeTypes(ctx), + }, + }, + Computed: true, + }, + "user": schema.SingleNestedAttribute{ + Attributes: map[string]schema.Attribute{ + "email": schema.StringAttribute{ + Computed: true, + Description: "Email address of the user associated with the account.", + MarkdownDescription: "Email address of the user associated with the account.", + }, + "id": schema.Int64Attribute{ + Computed: true, + Description: "The user ID associated with the account.", + MarkdownDescription: "The user ID associated with the account.", + }, + }, + CustomType: UserType{ + ObjectType: types.ObjectType{ + AttrTypes: UserValue{}.AttributeTypes(ctx), + }, + }, + Computed: true, + }, + }, + } +} + +type MeModel struct { + Account AccountValue `tfsdk:"account"` + Token TokenValue `tfsdk:"token"` + User UserValue `tfsdk:"user"` +} + +var _ basetypes.ObjectTypable = AccountType{} + +type AccountType struct { + basetypes.ObjectType +} + +func (t AccountType) Equal(o attr.Type) bool { + other, ok := o.(AccountType) + + if !ok { + return false + } + + return t.ObjectType.Equal(other.ObjectType) +} + +func (t AccountType) String() string { + return "AccountType" +} + +func (t AccountType) ValueFromObject(ctx context.Context, in basetypes.ObjectValue) (basetypes.ObjectValuable, diag.Diagnostics) { + var diags diag.Diagnostics + + attributes := in.Attributes() + + idAttribute, ok := attributes["id"] + + if !ok { + diags.AddError( + "Attribute Missing", + `id is missing from object`) + + return nil, diags + } + + idVal, ok := idAttribute.(basetypes.StringValue) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`id expected to be basetypes.StringValue, was: %T`, idAttribute)) + } + + nameAttribute, ok := attributes["name"] + + if !ok { + diags.AddError( + "Attribute Missing", + `name is missing from object`) + + return nil, diags + } + + nameVal, ok := nameAttribute.(basetypes.StringValue) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`name expected to be basetypes.StringValue, was: %T`, nameAttribute)) + } + + if diags.HasError() { + return nil, diags + } + + return AccountValue{ + Id: idVal, + Name: nameVal, + state: attr.ValueStateKnown, + }, diags +} + +func NewAccountValueNull() AccountValue { + return AccountValue{ + state: attr.ValueStateNull, + } +} + +func NewAccountValueUnknown() AccountValue { + return AccountValue{ + state: attr.ValueStateUnknown, + } +} + +func NewAccountValue(attributeTypes map[string]attr.Type, attributes map[string]attr.Value) (AccountValue, diag.Diagnostics) { + var diags diag.Diagnostics + + // Reference: https://github.com/hashicorp/terraform-plugin-framework/issues/521 + ctx := context.Background() + + for name, attributeType := range attributeTypes { + attribute, ok := attributes[name] + + if !ok { + diags.AddError( + "Missing AccountValue Attribute Value", + "While creating a AccountValue value, a missing attribute value was detected. "+ + "A AccountValue must contain values for all attributes, even if null or unknown. "+ + "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ + fmt.Sprintf("AccountValue Attribute Name (%s) Expected Type: %s", name, attributeType.String()), + ) + + continue + } + + if !attributeType.Equal(attribute.Type(ctx)) { + diags.AddError( + "Invalid AccountValue Attribute Type", + "While creating a AccountValue value, an invalid attribute value was detected. "+ + "A AccountValue must use a matching attribute type for the value. "+ + "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ + fmt.Sprintf("AccountValue Attribute Name (%s) Expected Type: %s\n", name, attributeType.String())+ + fmt.Sprintf("AccountValue Attribute Name (%s) Given Type: %s", name, attribute.Type(ctx)), + ) + } + } + + for name := range attributes { + _, ok := attributeTypes[name] + + if !ok { + diags.AddError( + "Extra AccountValue Attribute Value", + "While creating a AccountValue value, an extra attribute value was detected. "+ + "A AccountValue must not contain values beyond the expected attribute types. "+ + "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ + fmt.Sprintf("Extra AccountValue Attribute Name: %s", name), + ) + } + } + + if diags.HasError() { + return NewAccountValueUnknown(), diags + } + + idAttribute, ok := attributes["id"] + + if !ok { + diags.AddError( + "Attribute Missing", + `id is missing from object`) + + return NewAccountValueUnknown(), diags + } + + idVal, ok := idAttribute.(basetypes.StringValue) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`id expected to be basetypes.StringValue, was: %T`, idAttribute)) + } + + nameAttribute, ok := attributes["name"] + + if !ok { + diags.AddError( + "Attribute Missing", + `name is missing from object`) + + return NewAccountValueUnknown(), diags + } + + nameVal, ok := nameAttribute.(basetypes.StringValue) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`name expected to be basetypes.StringValue, was: %T`, nameAttribute)) + } + + if diags.HasError() { + return NewAccountValueUnknown(), diags + } + + return AccountValue{ + Id: idVal, + Name: nameVal, + state: attr.ValueStateKnown, + }, diags +} + +func NewAccountValueMust(attributeTypes map[string]attr.Type, attributes map[string]attr.Value) AccountValue { + object, diags := NewAccountValue(attributeTypes, attributes) + + if diags.HasError() { + // This could potentially be added to the diag package. + diagsStrings := make([]string, 0, len(diags)) + + for _, diagnostic := range diags { + diagsStrings = append(diagsStrings, fmt.Sprintf( + "%s | %s | %s", + diagnostic.Severity(), + diagnostic.Summary(), + diagnostic.Detail())) + } + + panic("NewAccountValueMust received error(s): " + strings.Join(diagsStrings, "\n")) + } + + return object +} + +func (t AccountType) ValueFromTerraform(ctx context.Context, in tftypes.Value) (attr.Value, error) { + if in.Type() == nil { + return NewAccountValueNull(), nil + } + + if !in.Type().Equal(t.TerraformType(ctx)) { + return nil, fmt.Errorf("expected %s, got %s", t.TerraformType(ctx), in.Type()) + } + + if !in.IsKnown() { + return NewAccountValueUnknown(), nil + } + + if in.IsNull() { + return NewAccountValueNull(), nil + } + + attributes := map[string]attr.Value{} + + val := map[string]tftypes.Value{} + + err := in.As(&val) + + if err != nil { + return nil, err + } + + for k, v := range val { + a, err := t.AttrTypes[k].ValueFromTerraform(ctx, v) + + if err != nil { + return nil, err + } + + attributes[k] = a + } + + return NewAccountValueMust(AccountValue{}.AttributeTypes(ctx), attributes), nil +} + +func (t AccountType) ValueType(ctx context.Context) attr.Value { + return AccountValue{} +} + +var _ basetypes.ObjectValuable = AccountValue{} + +type AccountValue struct { + Id basetypes.StringValue `tfsdk:"id"` + Name basetypes.StringValue `tfsdk:"name"` + state attr.ValueState +} + +func (v AccountValue) ToTerraformValue(ctx context.Context) (tftypes.Value, error) { + attrTypes := make(map[string]tftypes.Type, 2) + + var val tftypes.Value + var err error + + attrTypes["id"] = basetypes.StringType{}.TerraformType(ctx) + attrTypes["name"] = basetypes.StringType{}.TerraformType(ctx) + + objectType := tftypes.Object{AttributeTypes: attrTypes} + + switch v.state { + case attr.ValueStateKnown: + vals := make(map[string]tftypes.Value, 2) + + val, err = v.Id.ToTerraformValue(ctx) + + if err != nil { + return tftypes.NewValue(objectType, tftypes.UnknownValue), err + } + + vals["id"] = val + + val, err = v.Name.ToTerraformValue(ctx) + + if err != nil { + return tftypes.NewValue(objectType, tftypes.UnknownValue), err + } + + vals["name"] = val + + if err := tftypes.ValidateValue(objectType, vals); err != nil { + return tftypes.NewValue(objectType, tftypes.UnknownValue), err + } + + return tftypes.NewValue(objectType, vals), nil + case attr.ValueStateNull: + return tftypes.NewValue(objectType, nil), nil + case attr.ValueStateUnknown: + return tftypes.NewValue(objectType, tftypes.UnknownValue), nil + default: + panic(fmt.Sprintf("unhandled Object state in ToTerraformValue: %s", v.state)) + } +} + +func (v AccountValue) IsNull() bool { + return v.state == attr.ValueStateNull +} + +func (v AccountValue) IsUnknown() bool { + return v.state == attr.ValueStateUnknown +} + +func (v AccountValue) String() string { + return "AccountValue" +} + +func (v AccountValue) ToObjectValue(ctx context.Context) (basetypes.ObjectValue, diag.Diagnostics) { + var diags diag.Diagnostics + + attributeTypes := map[string]attr.Type{ + "id": basetypes.StringType{}, + "name": basetypes.StringType{}, + } + + if v.IsNull() { + return types.ObjectNull(attributeTypes), diags + } + + if v.IsUnknown() { + return types.ObjectUnknown(attributeTypes), diags + } + + objVal, diags := types.ObjectValue( + attributeTypes, + map[string]attr.Value{ + "id": v.Id, + "name": v.Name, + }) + + return objVal, diags +} + +func (v AccountValue) Equal(o attr.Value) bool { + other, ok := o.(AccountValue) + + if !ok { + return false + } + + if v.state != other.state { + return false + } + + if v.state != attr.ValueStateKnown { + return true + } + + if !v.Id.Equal(other.Id) { + return false + } + + if !v.Name.Equal(other.Name) { + return false + } + + return true +} + +func (v AccountValue) Type(ctx context.Context) attr.Type { + return AccountType{ + basetypes.ObjectType{ + AttrTypes: v.AttributeTypes(ctx), + }, + } +} + +func (v AccountValue) AttributeTypes(ctx context.Context) map[string]attr.Type { + return map[string]attr.Type{ + "id": basetypes.StringType{}, + "name": basetypes.StringType{}, + } +} + +var _ basetypes.ObjectTypable = TokenType{} + +type TokenType struct { + basetypes.ObjectType +} + +func (t TokenType) Equal(o attr.Type) bool { + other, ok := o.(TokenType) + + if !ok { + return false + } + + return t.ObjectType.Equal(other.ObjectType) +} + +func (t TokenType) String() string { + return "TokenType" +} + +func (t TokenType) ValueFromObject(ctx context.Context, in basetypes.ObjectValue) (basetypes.ObjectValuable, diag.Diagnostics) { + var diags diag.Diagnostics + + attributes := in.Attributes() + + capabilitiesAttribute, ok := attributes["capabilities"] + + if !ok { + diags.AddError( + "Attribute Missing", + `capabilities is missing from object`) + + return nil, diags + } + + capabilitiesVal, ok := capabilitiesAttribute.(basetypes.ListValue) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`capabilities expected to be basetypes.ListValue, was: %T`, capabilitiesAttribute)) + } + + expiresAtAttribute, ok := attributes["expires_at"] + + if !ok { + diags.AddError( + "Attribute Missing", + `expires_at is missing from object`) + + return nil, diags + } + + expiresAtVal, ok := expiresAtAttribute.(basetypes.StringValue) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`expires_at expected to be basetypes.StringValue, was: %T`, expiresAtAttribute)) + } + + idAttribute, ok := attributes["id"] + + if !ok { + diags.AddError( + "Attribute Missing", + `id is missing from object`) + + return nil, diags + } + + idVal, ok := idAttribute.(basetypes.StringValue) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`id expected to be basetypes.StringValue, was: %T`, idAttribute)) + } + + lastUsedAtAttribute, ok := attributes["last_used_at"] + + if !ok { + diags.AddError( + "Attribute Missing", + `last_used_at is missing from object`) + + return nil, diags + } + + lastUsedAtVal, ok := lastUsedAtAttribute.(basetypes.StringValue) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`last_used_at expected to be basetypes.StringValue, was: %T`, lastUsedAtAttribute)) + } + + nameAttribute, ok := attributes["name"] + + if !ok { + diags.AddError( + "Attribute Missing", + `name is missing from object`) + + return nil, diags + } + + nameVal, ok := nameAttribute.(basetypes.StringValue) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`name expected to be basetypes.StringValue, was: %T`, nameAttribute)) + } + + if diags.HasError() { + return nil, diags + } + + return TokenValue{ + Capabilities: capabilitiesVal, + ExpiresAt: expiresAtVal, + Id: idVal, + LastUsedAt: lastUsedAtVal, + Name: nameVal, + state: attr.ValueStateKnown, + }, diags +} + +func NewTokenValueNull() TokenValue { + return TokenValue{ + state: attr.ValueStateNull, + } +} + +func NewTokenValueUnknown() TokenValue { + return TokenValue{ + state: attr.ValueStateUnknown, + } +} + +func NewTokenValue(attributeTypes map[string]attr.Type, attributes map[string]attr.Value) (TokenValue, diag.Diagnostics) { + var diags diag.Diagnostics + + // Reference: https://github.com/hashicorp/terraform-plugin-framework/issues/521 + ctx := context.Background() + + for name, attributeType := range attributeTypes { + attribute, ok := attributes[name] + + if !ok { + diags.AddError( + "Missing TokenValue Attribute Value", + "While creating a TokenValue value, a missing attribute value was detected. "+ + "A TokenValue must contain values for all attributes, even if null or unknown. "+ + "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ + fmt.Sprintf("TokenValue Attribute Name (%s) Expected Type: %s", name, attributeType.String()), + ) + + continue + } + + if !attributeType.Equal(attribute.Type(ctx)) { + diags.AddError( + "Invalid TokenValue Attribute Type", + "While creating a TokenValue value, an invalid attribute value was detected. "+ + "A TokenValue must use a matching attribute type for the value. "+ + "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ + fmt.Sprintf("TokenValue Attribute Name (%s) Expected Type: %s\n", name, attributeType.String())+ + fmt.Sprintf("TokenValue Attribute Name (%s) Given Type: %s", name, attribute.Type(ctx)), + ) + } + } + + for name := range attributes { + _, ok := attributeTypes[name] + + if !ok { + diags.AddError( + "Extra TokenValue Attribute Value", + "While creating a TokenValue value, an extra attribute value was detected. "+ + "A TokenValue must not contain values beyond the expected attribute types. "+ + "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ + fmt.Sprintf("Extra TokenValue Attribute Name: %s", name), + ) + } + } + + if diags.HasError() { + return NewTokenValueUnknown(), diags + } + + capabilitiesAttribute, ok := attributes["capabilities"] + + if !ok { + diags.AddError( + "Attribute Missing", + `capabilities is missing from object`) + + return NewTokenValueUnknown(), diags + } + + capabilitiesVal, ok := capabilitiesAttribute.(basetypes.ListValue) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`capabilities expected to be basetypes.ListValue, was: %T`, capabilitiesAttribute)) + } + + expiresAtAttribute, ok := attributes["expires_at"] + + if !ok { + diags.AddError( + "Attribute Missing", + `expires_at is missing from object`) + + return NewTokenValueUnknown(), diags + } + + expiresAtVal, ok := expiresAtAttribute.(basetypes.StringValue) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`expires_at expected to be basetypes.StringValue, was: %T`, expiresAtAttribute)) + } + + idAttribute, ok := attributes["id"] + + if !ok { + diags.AddError( + "Attribute Missing", + `id is missing from object`) + + return NewTokenValueUnknown(), diags + } + + idVal, ok := idAttribute.(basetypes.StringValue) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`id expected to be basetypes.StringValue, was: %T`, idAttribute)) + } + + lastUsedAtAttribute, ok := attributes["last_used_at"] + + if !ok { + diags.AddError( + "Attribute Missing", + `last_used_at is missing from object`) + + return NewTokenValueUnknown(), diags + } + + lastUsedAtVal, ok := lastUsedAtAttribute.(basetypes.StringValue) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`last_used_at expected to be basetypes.StringValue, was: %T`, lastUsedAtAttribute)) + } + + nameAttribute, ok := attributes["name"] + + if !ok { + diags.AddError( + "Attribute Missing", + `name is missing from object`) + + return NewTokenValueUnknown(), diags + } + + nameVal, ok := nameAttribute.(basetypes.StringValue) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`name expected to be basetypes.StringValue, was: %T`, nameAttribute)) + } + + if diags.HasError() { + return NewTokenValueUnknown(), diags + } + + return TokenValue{ + Capabilities: capabilitiesVal, + ExpiresAt: expiresAtVal, + Id: idVal, + LastUsedAt: lastUsedAtVal, + Name: nameVal, + state: attr.ValueStateKnown, + }, diags +} + +func NewTokenValueMust(attributeTypes map[string]attr.Type, attributes map[string]attr.Value) TokenValue { + object, diags := NewTokenValue(attributeTypes, attributes) + + if diags.HasError() { + // This could potentially be added to the diag package. + diagsStrings := make([]string, 0, len(diags)) + + for _, diagnostic := range diags { + diagsStrings = append(diagsStrings, fmt.Sprintf( + "%s | %s | %s", + diagnostic.Severity(), + diagnostic.Summary(), + diagnostic.Detail())) + } + + panic("NewTokenValueMust received error(s): " + strings.Join(diagsStrings, "\n")) + } + + return object +} + +func (t TokenType) ValueFromTerraform(ctx context.Context, in tftypes.Value) (attr.Value, error) { + if in.Type() == nil { + return NewTokenValueNull(), nil + } + + if !in.Type().Equal(t.TerraformType(ctx)) { + return nil, fmt.Errorf("expected %s, got %s", t.TerraformType(ctx), in.Type()) + } + + if !in.IsKnown() { + return NewTokenValueUnknown(), nil + } + + if in.IsNull() { + return NewTokenValueNull(), nil + } + + attributes := map[string]attr.Value{} + + val := map[string]tftypes.Value{} + + err := in.As(&val) + + if err != nil { + return nil, err + } + + for k, v := range val { + a, err := t.AttrTypes[k].ValueFromTerraform(ctx, v) + + if err != nil { + return nil, err + } + + attributes[k] = a + } + + return NewTokenValueMust(TokenValue{}.AttributeTypes(ctx), attributes), nil +} + +func (t TokenType) ValueType(ctx context.Context) attr.Value { + return TokenValue{} +} + +var _ basetypes.ObjectValuable = TokenValue{} + +type TokenValue struct { + Capabilities basetypes.ListValue `tfsdk:"capabilities"` + ExpiresAt basetypes.StringValue `tfsdk:"expires_at"` + Id basetypes.StringValue `tfsdk:"id"` + LastUsedAt basetypes.StringValue `tfsdk:"last_used_at"` + Name basetypes.StringValue `tfsdk:"name"` + state attr.ValueState +} + +func (v TokenValue) ToTerraformValue(ctx context.Context) (tftypes.Value, error) { + attrTypes := make(map[string]tftypes.Type, 5) + + var val tftypes.Value + var err error + + attrTypes["capabilities"] = basetypes.ListType{ + ElemType: types.StringType, + }.TerraformType(ctx) + attrTypes["expires_at"] = basetypes.StringType{}.TerraformType(ctx) + attrTypes["id"] = basetypes.StringType{}.TerraformType(ctx) + attrTypes["last_used_at"] = basetypes.StringType{}.TerraformType(ctx) + attrTypes["name"] = basetypes.StringType{}.TerraformType(ctx) + + objectType := tftypes.Object{AttributeTypes: attrTypes} + + switch v.state { + case attr.ValueStateKnown: + vals := make(map[string]tftypes.Value, 5) + + val, err = v.Capabilities.ToTerraformValue(ctx) + + if err != nil { + return tftypes.NewValue(objectType, tftypes.UnknownValue), err + } + + vals["capabilities"] = val + + val, err = v.ExpiresAt.ToTerraformValue(ctx) + + if err != nil { + return tftypes.NewValue(objectType, tftypes.UnknownValue), err + } + + vals["expires_at"] = val + + val, err = v.Id.ToTerraformValue(ctx) + + if err != nil { + return tftypes.NewValue(objectType, tftypes.UnknownValue), err + } + + vals["id"] = val + + val, err = v.LastUsedAt.ToTerraformValue(ctx) + + if err != nil { + return tftypes.NewValue(objectType, tftypes.UnknownValue), err + } + + vals["last_used_at"] = val + + val, err = v.Name.ToTerraformValue(ctx) + + if err != nil { + return tftypes.NewValue(objectType, tftypes.UnknownValue), err + } + + vals["name"] = val + + if err := tftypes.ValidateValue(objectType, vals); err != nil { + return tftypes.NewValue(objectType, tftypes.UnknownValue), err + } + + return tftypes.NewValue(objectType, vals), nil + case attr.ValueStateNull: + return tftypes.NewValue(objectType, nil), nil + case attr.ValueStateUnknown: + return tftypes.NewValue(objectType, tftypes.UnknownValue), nil + default: + panic(fmt.Sprintf("unhandled Object state in ToTerraformValue: %s", v.state)) + } +} + +func (v TokenValue) IsNull() bool { + return v.state == attr.ValueStateNull +} + +func (v TokenValue) IsUnknown() bool { + return v.state == attr.ValueStateUnknown +} + +func (v TokenValue) String() string { + return "TokenValue" +} + +func (v TokenValue) ToObjectValue(ctx context.Context) (basetypes.ObjectValue, diag.Diagnostics) { + var diags diag.Diagnostics + + var capabilitiesVal basetypes.ListValue + switch { + case v.Capabilities.IsUnknown(): + capabilitiesVal = types.ListUnknown(types.StringType) + case v.Capabilities.IsNull(): + capabilitiesVal = types.ListNull(types.StringType) + default: + var d diag.Diagnostics + capabilitiesVal, d = types.ListValue(types.StringType, v.Capabilities.Elements()) + diags.Append(d...) + } + + if diags.HasError() { + return types.ObjectUnknown(map[string]attr.Type{ + "capabilities": basetypes.ListType{ + ElemType: types.StringType, + }, + "expires_at": basetypes.StringType{}, + "id": basetypes.StringType{}, + "last_used_at": basetypes.StringType{}, + "name": basetypes.StringType{}, + }), diags + } + + attributeTypes := map[string]attr.Type{ + "capabilities": basetypes.ListType{ + ElemType: types.StringType, + }, + "expires_at": basetypes.StringType{}, + "id": basetypes.StringType{}, + "last_used_at": basetypes.StringType{}, + "name": basetypes.StringType{}, + } + + if v.IsNull() { + return types.ObjectNull(attributeTypes), diags + } + + if v.IsUnknown() { + return types.ObjectUnknown(attributeTypes), diags + } + + objVal, diags := types.ObjectValue( + attributeTypes, + map[string]attr.Value{ + "capabilities": capabilitiesVal, + "expires_at": v.ExpiresAt, + "id": v.Id, + "last_used_at": v.LastUsedAt, + "name": v.Name, + }) + + return objVal, diags +} + +func (v TokenValue) Equal(o attr.Value) bool { + other, ok := o.(TokenValue) + + if !ok { + return false + } + + if v.state != other.state { + return false + } + + if v.state != attr.ValueStateKnown { + return true + } + + if !v.Capabilities.Equal(other.Capabilities) { + return false + } + + if !v.ExpiresAt.Equal(other.ExpiresAt) { + return false + } + + if !v.Id.Equal(other.Id) { + return false + } + + if !v.LastUsedAt.Equal(other.LastUsedAt) { + return false + } + + if !v.Name.Equal(other.Name) { + return false + } + + return true +} + +func (v TokenValue) Type(ctx context.Context) attr.Type { + return TokenType{ + basetypes.ObjectType{ + AttrTypes: v.AttributeTypes(ctx), + }, + } +} + +func (v TokenValue) AttributeTypes(ctx context.Context) map[string]attr.Type { + return map[string]attr.Type{ + "capabilities": basetypes.ListType{ + ElemType: types.StringType, + }, + "expires_at": basetypes.StringType{}, + "id": basetypes.StringType{}, + "last_used_at": basetypes.StringType{}, + "name": basetypes.StringType{}, + } +} + +var _ basetypes.ObjectTypable = UserType{} + +type UserType struct { + basetypes.ObjectType +} + +func (t UserType) Equal(o attr.Type) bool { + other, ok := o.(UserType) + + if !ok { + return false + } + + return t.ObjectType.Equal(other.ObjectType) +} + +func (t UserType) String() string { + return "UserType" +} + +func (t UserType) ValueFromObject(ctx context.Context, in basetypes.ObjectValue) (basetypes.ObjectValuable, diag.Diagnostics) { + var diags diag.Diagnostics + + attributes := in.Attributes() + + emailAttribute, ok := attributes["email"] + + if !ok { + diags.AddError( + "Attribute Missing", + `email is missing from object`) + + return nil, diags + } + + emailVal, ok := emailAttribute.(basetypes.StringValue) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`email expected to be basetypes.StringValue, was: %T`, emailAttribute)) + } + + idAttribute, ok := attributes["id"] + + if !ok { + diags.AddError( + "Attribute Missing", + `id is missing from object`) + + return nil, diags + } + + idVal, ok := idAttribute.(basetypes.Int64Value) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`id expected to be basetypes.Int64Value, was: %T`, idAttribute)) + } + + if diags.HasError() { + return nil, diags + } + + return UserValue{ + Email: emailVal, + Id: idVal, + state: attr.ValueStateKnown, + }, diags +} + +func NewUserValueNull() UserValue { + return UserValue{ + state: attr.ValueStateNull, + } +} + +func NewUserValueUnknown() UserValue { + return UserValue{ + state: attr.ValueStateUnknown, + } +} + +func NewUserValue(attributeTypes map[string]attr.Type, attributes map[string]attr.Value) (UserValue, diag.Diagnostics) { + var diags diag.Diagnostics + + // Reference: https://github.com/hashicorp/terraform-plugin-framework/issues/521 + ctx := context.Background() + + for name, attributeType := range attributeTypes { + attribute, ok := attributes[name] + + if !ok { + diags.AddError( + "Missing UserValue Attribute Value", + "While creating a UserValue value, a missing attribute value was detected. "+ + "A UserValue must contain values for all attributes, even if null or unknown. "+ + "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ + fmt.Sprintf("UserValue Attribute Name (%s) Expected Type: %s", name, attributeType.String()), + ) + + continue + } + + if !attributeType.Equal(attribute.Type(ctx)) { + diags.AddError( + "Invalid UserValue Attribute Type", + "While creating a UserValue value, an invalid attribute value was detected. "+ + "A UserValue must use a matching attribute type for the value. "+ + "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ + fmt.Sprintf("UserValue Attribute Name (%s) Expected Type: %s\n", name, attributeType.String())+ + fmt.Sprintf("UserValue Attribute Name (%s) Given Type: %s", name, attribute.Type(ctx)), + ) + } + } + + for name := range attributes { + _, ok := attributeTypes[name] + + if !ok { + diags.AddError( + "Extra UserValue Attribute Value", + "While creating a UserValue value, an extra attribute value was detected. "+ + "A UserValue must not contain values beyond the expected attribute types. "+ + "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ + fmt.Sprintf("Extra UserValue Attribute Name: %s", name), + ) + } + } + + if diags.HasError() { + return NewUserValueUnknown(), diags + } + + emailAttribute, ok := attributes["email"] + + if !ok { + diags.AddError( + "Attribute Missing", + `email is missing from object`) + + return NewUserValueUnknown(), diags + } + + emailVal, ok := emailAttribute.(basetypes.StringValue) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`email expected to be basetypes.StringValue, was: %T`, emailAttribute)) + } + + idAttribute, ok := attributes["id"] + + if !ok { + diags.AddError( + "Attribute Missing", + `id is missing from object`) + + return NewUserValueUnknown(), diags + } + + idVal, ok := idAttribute.(basetypes.Int64Value) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`id expected to be basetypes.Int64Value, was: %T`, idAttribute)) + } + + if diags.HasError() { + return NewUserValueUnknown(), diags + } + + return UserValue{ + Email: emailVal, + Id: idVal, + state: attr.ValueStateKnown, + }, diags +} + +func NewUserValueMust(attributeTypes map[string]attr.Type, attributes map[string]attr.Value) UserValue { + object, diags := NewUserValue(attributeTypes, attributes) + + if diags.HasError() { + // This could potentially be added to the diag package. + diagsStrings := make([]string, 0, len(diags)) + + for _, diagnostic := range diags { + diagsStrings = append(diagsStrings, fmt.Sprintf( + "%s | %s | %s", + diagnostic.Severity(), + diagnostic.Summary(), + diagnostic.Detail())) + } + + panic("NewUserValueMust received error(s): " + strings.Join(diagsStrings, "\n")) + } + + return object +} + +func (t UserType) ValueFromTerraform(ctx context.Context, in tftypes.Value) (attr.Value, error) { + if in.Type() == nil { + return NewUserValueNull(), nil + } + + if !in.Type().Equal(t.TerraformType(ctx)) { + return nil, fmt.Errorf("expected %s, got %s", t.TerraformType(ctx), in.Type()) + } + + if !in.IsKnown() { + return NewUserValueUnknown(), nil + } + + if in.IsNull() { + return NewUserValueNull(), nil + } + + attributes := map[string]attr.Value{} + + val := map[string]tftypes.Value{} + + err := in.As(&val) + + if err != nil { + return nil, err + } + + for k, v := range val { + a, err := t.AttrTypes[k].ValueFromTerraform(ctx, v) + + if err != nil { + return nil, err + } + + attributes[k] = a + } + + return NewUserValueMust(UserValue{}.AttributeTypes(ctx), attributes), nil +} + +func (t UserType) ValueType(ctx context.Context) attr.Value { + return UserValue{} +} + +var _ basetypes.ObjectValuable = UserValue{} + +type UserValue struct { + Email basetypes.StringValue `tfsdk:"email"` + Id basetypes.Int64Value `tfsdk:"id"` + state attr.ValueState +} + +func (v UserValue) ToTerraformValue(ctx context.Context) (tftypes.Value, error) { + attrTypes := make(map[string]tftypes.Type, 2) + + var val tftypes.Value + var err error + + attrTypes["email"] = basetypes.StringType{}.TerraformType(ctx) + attrTypes["id"] = basetypes.Int64Type{}.TerraformType(ctx) + + objectType := tftypes.Object{AttributeTypes: attrTypes} + + switch v.state { + case attr.ValueStateKnown: + vals := make(map[string]tftypes.Value, 2) + + val, err = v.Email.ToTerraformValue(ctx) + + if err != nil { + return tftypes.NewValue(objectType, tftypes.UnknownValue), err + } + + vals["email"] = val + + val, err = v.Id.ToTerraformValue(ctx) + + if err != nil { + return tftypes.NewValue(objectType, tftypes.UnknownValue), err + } + + vals["id"] = val + + if err := tftypes.ValidateValue(objectType, vals); err != nil { + return tftypes.NewValue(objectType, tftypes.UnknownValue), err + } + + return tftypes.NewValue(objectType, vals), nil + case attr.ValueStateNull: + return tftypes.NewValue(objectType, nil), nil + case attr.ValueStateUnknown: + return tftypes.NewValue(objectType, tftypes.UnknownValue), nil + default: + panic(fmt.Sprintf("unhandled Object state in ToTerraformValue: %s", v.state)) + } +} + +func (v UserValue) IsNull() bool { + return v.state == attr.ValueStateNull +} + +func (v UserValue) IsUnknown() bool { + return v.state == attr.ValueStateUnknown +} + +func (v UserValue) String() string { + return "UserValue" +} + +func (v UserValue) ToObjectValue(ctx context.Context) (basetypes.ObjectValue, diag.Diagnostics) { + var diags diag.Diagnostics + + attributeTypes := map[string]attr.Type{ + "email": basetypes.StringType{}, + "id": basetypes.Int64Type{}, + } + + if v.IsNull() { + return types.ObjectNull(attributeTypes), diags + } + + if v.IsUnknown() { + return types.ObjectUnknown(attributeTypes), diags + } + + objVal, diags := types.ObjectValue( + attributeTypes, + map[string]attr.Value{ + "email": v.Email, + "id": v.Id, + }) + + return objVal, diags +} + +func (v UserValue) Equal(o attr.Value) bool { + other, ok := o.(UserValue) + + if !ok { + return false + } + + if v.state != other.state { + return false + } + + if v.state != attr.ValueStateKnown { + return true + } + + if !v.Email.Equal(other.Email) { + return false + } + + if !v.Id.Equal(other.Id) { + return false + } + + return true +} + +func (v UserValue) Type(ctx context.Context) attr.Type { + return UserType{ + basetypes.ObjectType{ + AttrTypes: v.AttributeTypes(ctx), + }, + } +} + +func (v UserValue) AttributeTypes(ctx context.Context) map[string]attr.Type { + return map[string]attr.Type{ + "email": basetypes.StringType{}, + "id": basetypes.Int64Type{}, + } +} diff --git a/internal/provider/codegen/datasource_namespaces/namespaces_data_source_gen.go b/internal/provider/codegen/datasource_namespaces/namespaces_data_source_gen.go new file mode 100644 index 0000000..85c647f --- /dev/null +++ b/internal/provider/codegen/datasource_namespaces/namespaces_data_source_gen.go @@ -0,0 +1,1334 @@ +// Code generated by terraform-plugin-framework-generator DO NOT EDIT. + +package datasource_namespaces + +import ( + "context" + "fmt" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" + "github.com/hashicorp/terraform-plugin-go/tftypes" + "strings" + + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" +) + +func NamespacesDataSourceSchema(ctx context.Context) schema.Schema { + return schema.Schema{ + Attributes: map[string]schema.Attribute{ + "app_id": schema.StringAttribute{ + Required: true, + Description: "The application ID.", + MarkdownDescription: "The application ID.", + }, + "namespaces": schema.SetNestedAttribute{ + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "app_id": schema.StringAttribute{ + Computed: true, + Description: "The ID of the application the namespace belongs to.", + MarkdownDescription: "The ID of the application the namespace belongs to.", + }, + "authenticated": schema.BoolAttribute{ + Computed: true, + Description: "If `true`, clients will not be permitted to use (including to attach, publish, or subscribe) any channels within this namespace unless they are identified, that is, authenticated using a client ID. See the documentation for identified clients for more details.", + MarkdownDescription: "If `true`, clients will not be permitted to use (including to attach, publish, or subscribe) any channels within this namespace unless they are identified, that is, authenticated using a client ID. See the documentation for identified clients for more details.", + }, + "batching_enabled": schema.BoolAttribute{ + Computed: true, + Description: "If `true`, channels within this namespace will start batching inbound messages instead of sending them out immediately to subscribers.", + MarkdownDescription: "If `true`, channels within this namespace will start batching inbound messages instead of sending them out immediately to subscribers.", + }, + "batching_interval": schema.Int64Attribute{ + Computed: true, + Description: "If `true`, the batching interval, configurable from 20ms to 1s, temporarily holds and aggregates incoming messages. These messages are then combined and delivered as a single batch once the interval elapses or a size limit is reached (before the interval elapses).", + MarkdownDescription: "If `true`, the batching interval, configurable from 20ms to 1s, temporarily holds and aggregates incoming messages. These messages are then combined and delivered as a single batch once the interval elapses or a size limit is reached (before the interval elapses).", + }, + "conflation_enabled": schema.BoolAttribute{ + Computed: true, + Description: "If `true`, enables conflation for channels within this namespace. Conflation will aggregate published messages for a set period of time and evaluate them against a conflation key. Only the most recent message that satisfies the key will be sent to subscribers at the end of the conflation interval.", + MarkdownDescription: "If `true`, enables conflation for channels within this namespace. Conflation will aggregate published messages for a set period of time and evaluate them against a conflation key. Only the most recent message that satisfies the key will be sent to subscribers at the end of the conflation interval.", + }, + "conflation_interval": schema.Int64Attribute{ + Computed: true, + Description: "The interval in milliseconds over which messages are held and evaluated for conflation.", + MarkdownDescription: "The interval in milliseconds over which messages are held and evaluated for conflation.", + }, + "conflation_key": schema.StringAttribute{ + Computed: true, + Description: "The key used to determine which messages should be conflated.", + MarkdownDescription: "The key used to determine which messages should be conflated.", + }, + "created": schema.Int64Attribute{ + Computed: true, + Description: "Unix timestamp representing the date and time of creation of the namespace.", + MarkdownDescription: "Unix timestamp representing the date and time of creation of the namespace.", + }, + "expose_timeserial": schema.BoolAttribute{ + Computed: true, + Description: "If `true`, messages received on a channel will contain a unique `timeserial` that can be referenced by later messages for use with message interactions.", + MarkdownDescription: "If `true`, messages received on a channel will contain a unique `timeserial` that can be referenced by later messages for use with message interactions.", + }, + "id": schema.StringAttribute{ + Computed: true, + Description: "The namespace or channel name that the channel rule will apply to. For example, if you specify `namespace` the namespace will be set to `namespace` and will match with channels `namespace:*` and `namespace`.", + MarkdownDescription: "The namespace or channel name that the channel rule will apply to. For example, if you specify `namespace` the namespace will be set to `namespace` and will match with channels `namespace:*` and `namespace`.", + }, + "modified": schema.Int64Attribute{ + Computed: true, + Description: "Unix timestamp representing the date and time of last modification of the namespace.", + MarkdownDescription: "Unix timestamp representing the date and time of last modification of the namespace.", + }, + "mutable_messages": schema.BoolAttribute{ + Computed: true, + Description: "If `true`, messages published on channels within this namespace can be updated or deleted after publishing. See the message interactions documentation for more details.", + MarkdownDescription: "If `true`, messages published on channels within this namespace can be updated or deleted after publishing. See the message interactions documentation for more details.", + }, + "persist_last": schema.BoolAttribute{ + Computed: true, + Description: "If `true`, the last message published on a channel will be stored for 365 days. You can access the stored message only by using the channel rewind mechanism and attaching with rewind=1. Please note that for each message stored, an additional message is deducted from your monthly allocation.", + MarkdownDescription: "If `true`, the last message published on a channel will be stored for 365 days. You can access the stored message only by using the channel rewind mechanism and attaching with rewind=1. Please note that for each message stored, an additional message is deducted from your monthly allocation.", + }, + "persisted": schema.BoolAttribute{ + Computed: true, + Description: "If `true`, all messages on a channel will be stored for 24 hours. You can access stored messages via the History API. Please note that for each message stored, an additional message is deducted from your monthly allocation.", + MarkdownDescription: "If `true`, all messages on a channel will be stored for 24 hours. You can access stored messages via the History API. Please note that for each message stored, an additional message is deducted from your monthly allocation.", + }, + "populate_channel_registry": schema.BoolAttribute{ + Computed: true, + Description: "If `true`, channels within this namespace are included in the channel registry, enabling channel enumeration via the Channel Metadata API.", + MarkdownDescription: "If `true`, channels within this namespace are included in the channel registry, enabling channel enumeration via the Channel Metadata API.", + }, + "push_enabled": schema.BoolAttribute{ + Computed: true, + Description: "If `true`, publishing messages with a push payload in the extras field is permitted and can trigger the delivery of a native push notification to registered devices for the channel.", + MarkdownDescription: "If `true`, publishing messages with a push payload in the extras field is permitted and can trigger the delivery of a native push notification to registered devices for the channel.", + }, + "tls_only": schema.BoolAttribute{ + Computed: true, + Description: "If `true`, only clients that are connected using TLS will be permitted to subscribe to any channels within this namespace.", + MarkdownDescription: "If `true`, only clients that are connected using TLS will be permitted to subscribe to any channels within this namespace.", + }, + }, + CustomType: NamespacesType{ + ObjectType: types.ObjectType{ + AttrTypes: NamespacesValue{}.AttributeTypes(ctx), + }, + }, + }, + Computed: true, + }, + }, + } +} + +type NamespacesModel struct { + AppId types.String `tfsdk:"app_id"` + Namespaces types.Set `tfsdk:"namespaces"` +} + +var _ basetypes.ObjectTypable = NamespacesType{} + +type NamespacesType struct { + basetypes.ObjectType +} + +func (t NamespacesType) Equal(o attr.Type) bool { + other, ok := o.(NamespacesType) + + if !ok { + return false + } + + return t.ObjectType.Equal(other.ObjectType) +} + +func (t NamespacesType) String() string { + return "NamespacesType" +} + +func (t NamespacesType) ValueFromObject(ctx context.Context, in basetypes.ObjectValue) (basetypes.ObjectValuable, diag.Diagnostics) { + var diags diag.Diagnostics + + attributes := in.Attributes() + + appIdAttribute, ok := attributes["app_id"] + + if !ok { + diags.AddError( + "Attribute Missing", + `app_id is missing from object`) + + return nil, diags + } + + appIdVal, ok := appIdAttribute.(basetypes.StringValue) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`app_id expected to be basetypes.StringValue, was: %T`, appIdAttribute)) + } + + authenticatedAttribute, ok := attributes["authenticated"] + + if !ok { + diags.AddError( + "Attribute Missing", + `authenticated is missing from object`) + + return nil, diags + } + + authenticatedVal, ok := authenticatedAttribute.(basetypes.BoolValue) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`authenticated expected to be basetypes.BoolValue, was: %T`, authenticatedAttribute)) + } + + batchingEnabledAttribute, ok := attributes["batching_enabled"] + + if !ok { + diags.AddError( + "Attribute Missing", + `batching_enabled is missing from object`) + + return nil, diags + } + + batchingEnabledVal, ok := batchingEnabledAttribute.(basetypes.BoolValue) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`batching_enabled expected to be basetypes.BoolValue, was: %T`, batchingEnabledAttribute)) + } + + batchingIntervalAttribute, ok := attributes["batching_interval"] + + if !ok { + diags.AddError( + "Attribute Missing", + `batching_interval is missing from object`) + + return nil, diags + } + + batchingIntervalVal, ok := batchingIntervalAttribute.(basetypes.Int64Value) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`batching_interval expected to be basetypes.Int64Value, was: %T`, batchingIntervalAttribute)) + } + + conflationEnabledAttribute, ok := attributes["conflation_enabled"] + + if !ok { + diags.AddError( + "Attribute Missing", + `conflation_enabled is missing from object`) + + return nil, diags + } + + conflationEnabledVal, ok := conflationEnabledAttribute.(basetypes.BoolValue) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`conflation_enabled expected to be basetypes.BoolValue, was: %T`, conflationEnabledAttribute)) + } + + conflationIntervalAttribute, ok := attributes["conflation_interval"] + + if !ok { + diags.AddError( + "Attribute Missing", + `conflation_interval is missing from object`) + + return nil, diags + } + + conflationIntervalVal, ok := conflationIntervalAttribute.(basetypes.Int64Value) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`conflation_interval expected to be basetypes.Int64Value, was: %T`, conflationIntervalAttribute)) + } + + conflationKeyAttribute, ok := attributes["conflation_key"] + + if !ok { + diags.AddError( + "Attribute Missing", + `conflation_key is missing from object`) + + return nil, diags + } + + conflationKeyVal, ok := conflationKeyAttribute.(basetypes.StringValue) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`conflation_key expected to be basetypes.StringValue, was: %T`, conflationKeyAttribute)) + } + + createdAttribute, ok := attributes["created"] + + if !ok { + diags.AddError( + "Attribute Missing", + `created is missing from object`) + + return nil, diags + } + + createdVal, ok := createdAttribute.(basetypes.Int64Value) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`created expected to be basetypes.Int64Value, was: %T`, createdAttribute)) + } + + exposeTimeserialAttribute, ok := attributes["expose_timeserial"] + + if !ok { + diags.AddError( + "Attribute Missing", + `expose_timeserial is missing from object`) + + return nil, diags + } + + exposeTimeserialVal, ok := exposeTimeserialAttribute.(basetypes.BoolValue) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`expose_timeserial expected to be basetypes.BoolValue, was: %T`, exposeTimeserialAttribute)) + } + + idAttribute, ok := attributes["id"] + + if !ok { + diags.AddError( + "Attribute Missing", + `id is missing from object`) + + return nil, diags + } + + idVal, ok := idAttribute.(basetypes.StringValue) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`id expected to be basetypes.StringValue, was: %T`, idAttribute)) + } + + modifiedAttribute, ok := attributes["modified"] + + if !ok { + diags.AddError( + "Attribute Missing", + `modified is missing from object`) + + return nil, diags + } + + modifiedVal, ok := modifiedAttribute.(basetypes.Int64Value) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`modified expected to be basetypes.Int64Value, was: %T`, modifiedAttribute)) + } + + mutableMessagesAttribute, ok := attributes["mutable_messages"] + + if !ok { + diags.AddError( + "Attribute Missing", + `mutable_messages is missing from object`) + + return nil, diags + } + + mutableMessagesVal, ok := mutableMessagesAttribute.(basetypes.BoolValue) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`mutable_messages expected to be basetypes.BoolValue, was: %T`, mutableMessagesAttribute)) + } + + persistLastAttribute, ok := attributes["persist_last"] + + if !ok { + diags.AddError( + "Attribute Missing", + `persist_last is missing from object`) + + return nil, diags + } + + persistLastVal, ok := persistLastAttribute.(basetypes.BoolValue) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`persist_last expected to be basetypes.BoolValue, was: %T`, persistLastAttribute)) + } + + persistedAttribute, ok := attributes["persisted"] + + if !ok { + diags.AddError( + "Attribute Missing", + `persisted is missing from object`) + + return nil, diags + } + + persistedVal, ok := persistedAttribute.(basetypes.BoolValue) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`persisted expected to be basetypes.BoolValue, was: %T`, persistedAttribute)) + } + + populateChannelRegistryAttribute, ok := attributes["populate_channel_registry"] + + if !ok { + diags.AddError( + "Attribute Missing", + `populate_channel_registry is missing from object`) + + return nil, diags + } + + populateChannelRegistryVal, ok := populateChannelRegistryAttribute.(basetypes.BoolValue) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`populate_channel_registry expected to be basetypes.BoolValue, was: %T`, populateChannelRegistryAttribute)) + } + + pushEnabledAttribute, ok := attributes["push_enabled"] + + if !ok { + diags.AddError( + "Attribute Missing", + `push_enabled is missing from object`) + + return nil, diags + } + + pushEnabledVal, ok := pushEnabledAttribute.(basetypes.BoolValue) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`push_enabled expected to be basetypes.BoolValue, was: %T`, pushEnabledAttribute)) + } + + tlsOnlyAttribute, ok := attributes["tls_only"] + + if !ok { + diags.AddError( + "Attribute Missing", + `tls_only is missing from object`) + + return nil, diags + } + + tlsOnlyVal, ok := tlsOnlyAttribute.(basetypes.BoolValue) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`tls_only expected to be basetypes.BoolValue, was: %T`, tlsOnlyAttribute)) + } + + if diags.HasError() { + return nil, diags + } + + return NamespacesValue{ + AppId: appIdVal, + Authenticated: authenticatedVal, + BatchingEnabled: batchingEnabledVal, + BatchingInterval: batchingIntervalVal, + ConflationEnabled: conflationEnabledVal, + ConflationInterval: conflationIntervalVal, + ConflationKey: conflationKeyVal, + Created: createdVal, + ExposeTimeserial: exposeTimeserialVal, + Id: idVal, + Modified: modifiedVal, + MutableMessages: mutableMessagesVal, + PersistLast: persistLastVal, + Persisted: persistedVal, + PopulateChannelRegistry: populateChannelRegistryVal, + PushEnabled: pushEnabledVal, + TlsOnly: tlsOnlyVal, + state: attr.ValueStateKnown, + }, diags +} + +func NewNamespacesValueNull() NamespacesValue { + return NamespacesValue{ + state: attr.ValueStateNull, + } +} + +func NewNamespacesValueUnknown() NamespacesValue { + return NamespacesValue{ + state: attr.ValueStateUnknown, + } +} + +func NewNamespacesValue(attributeTypes map[string]attr.Type, attributes map[string]attr.Value) (NamespacesValue, diag.Diagnostics) { + var diags diag.Diagnostics + + // Reference: https://github.com/hashicorp/terraform-plugin-framework/issues/521 + ctx := context.Background() + + for name, attributeType := range attributeTypes { + attribute, ok := attributes[name] + + if !ok { + diags.AddError( + "Missing NamespacesValue Attribute Value", + "While creating a NamespacesValue value, a missing attribute value was detected. "+ + "A NamespacesValue must contain values for all attributes, even if null or unknown. "+ + "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ + fmt.Sprintf("NamespacesValue Attribute Name (%s) Expected Type: %s", name, attributeType.String()), + ) + + continue + } + + if !attributeType.Equal(attribute.Type(ctx)) { + diags.AddError( + "Invalid NamespacesValue Attribute Type", + "While creating a NamespacesValue value, an invalid attribute value was detected. "+ + "A NamespacesValue must use a matching attribute type for the value. "+ + "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ + fmt.Sprintf("NamespacesValue Attribute Name (%s) Expected Type: %s\n", name, attributeType.String())+ + fmt.Sprintf("NamespacesValue Attribute Name (%s) Given Type: %s", name, attribute.Type(ctx)), + ) + } + } + + for name := range attributes { + _, ok := attributeTypes[name] + + if !ok { + diags.AddError( + "Extra NamespacesValue Attribute Value", + "While creating a NamespacesValue value, an extra attribute value was detected. "+ + "A NamespacesValue must not contain values beyond the expected attribute types. "+ + "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ + fmt.Sprintf("Extra NamespacesValue Attribute Name: %s", name), + ) + } + } + + if diags.HasError() { + return NewNamespacesValueUnknown(), diags + } + + appIdAttribute, ok := attributes["app_id"] + + if !ok { + diags.AddError( + "Attribute Missing", + `app_id is missing from object`) + + return NewNamespacesValueUnknown(), diags + } + + appIdVal, ok := appIdAttribute.(basetypes.StringValue) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`app_id expected to be basetypes.StringValue, was: %T`, appIdAttribute)) + } + + authenticatedAttribute, ok := attributes["authenticated"] + + if !ok { + diags.AddError( + "Attribute Missing", + `authenticated is missing from object`) + + return NewNamespacesValueUnknown(), diags + } + + authenticatedVal, ok := authenticatedAttribute.(basetypes.BoolValue) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`authenticated expected to be basetypes.BoolValue, was: %T`, authenticatedAttribute)) + } + + batchingEnabledAttribute, ok := attributes["batching_enabled"] + + if !ok { + diags.AddError( + "Attribute Missing", + `batching_enabled is missing from object`) + + return NewNamespacesValueUnknown(), diags + } + + batchingEnabledVal, ok := batchingEnabledAttribute.(basetypes.BoolValue) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`batching_enabled expected to be basetypes.BoolValue, was: %T`, batchingEnabledAttribute)) + } + + batchingIntervalAttribute, ok := attributes["batching_interval"] + + if !ok { + diags.AddError( + "Attribute Missing", + `batching_interval is missing from object`) + + return NewNamespacesValueUnknown(), diags + } + + batchingIntervalVal, ok := batchingIntervalAttribute.(basetypes.Int64Value) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`batching_interval expected to be basetypes.Int64Value, was: %T`, batchingIntervalAttribute)) + } + + conflationEnabledAttribute, ok := attributes["conflation_enabled"] + + if !ok { + diags.AddError( + "Attribute Missing", + `conflation_enabled is missing from object`) + + return NewNamespacesValueUnknown(), diags + } + + conflationEnabledVal, ok := conflationEnabledAttribute.(basetypes.BoolValue) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`conflation_enabled expected to be basetypes.BoolValue, was: %T`, conflationEnabledAttribute)) + } + + conflationIntervalAttribute, ok := attributes["conflation_interval"] + + if !ok { + diags.AddError( + "Attribute Missing", + `conflation_interval is missing from object`) + + return NewNamespacesValueUnknown(), diags + } + + conflationIntervalVal, ok := conflationIntervalAttribute.(basetypes.Int64Value) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`conflation_interval expected to be basetypes.Int64Value, was: %T`, conflationIntervalAttribute)) + } + + conflationKeyAttribute, ok := attributes["conflation_key"] + + if !ok { + diags.AddError( + "Attribute Missing", + `conflation_key is missing from object`) + + return NewNamespacesValueUnknown(), diags + } + + conflationKeyVal, ok := conflationKeyAttribute.(basetypes.StringValue) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`conflation_key expected to be basetypes.StringValue, was: %T`, conflationKeyAttribute)) + } + + createdAttribute, ok := attributes["created"] + + if !ok { + diags.AddError( + "Attribute Missing", + `created is missing from object`) + + return NewNamespacesValueUnknown(), diags + } + + createdVal, ok := createdAttribute.(basetypes.Int64Value) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`created expected to be basetypes.Int64Value, was: %T`, createdAttribute)) + } + + exposeTimeserialAttribute, ok := attributes["expose_timeserial"] + + if !ok { + diags.AddError( + "Attribute Missing", + `expose_timeserial is missing from object`) + + return NewNamespacesValueUnknown(), diags + } + + exposeTimeserialVal, ok := exposeTimeserialAttribute.(basetypes.BoolValue) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`expose_timeserial expected to be basetypes.BoolValue, was: %T`, exposeTimeserialAttribute)) + } + + idAttribute, ok := attributes["id"] + + if !ok { + diags.AddError( + "Attribute Missing", + `id is missing from object`) + + return NewNamespacesValueUnknown(), diags + } + + idVal, ok := idAttribute.(basetypes.StringValue) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`id expected to be basetypes.StringValue, was: %T`, idAttribute)) + } + + modifiedAttribute, ok := attributes["modified"] + + if !ok { + diags.AddError( + "Attribute Missing", + `modified is missing from object`) + + return NewNamespacesValueUnknown(), diags + } + + modifiedVal, ok := modifiedAttribute.(basetypes.Int64Value) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`modified expected to be basetypes.Int64Value, was: %T`, modifiedAttribute)) + } + + mutableMessagesAttribute, ok := attributes["mutable_messages"] + + if !ok { + diags.AddError( + "Attribute Missing", + `mutable_messages is missing from object`) + + return NewNamespacesValueUnknown(), diags + } + + mutableMessagesVal, ok := mutableMessagesAttribute.(basetypes.BoolValue) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`mutable_messages expected to be basetypes.BoolValue, was: %T`, mutableMessagesAttribute)) + } + + persistLastAttribute, ok := attributes["persist_last"] + + if !ok { + diags.AddError( + "Attribute Missing", + `persist_last is missing from object`) + + return NewNamespacesValueUnknown(), diags + } + + persistLastVal, ok := persistLastAttribute.(basetypes.BoolValue) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`persist_last expected to be basetypes.BoolValue, was: %T`, persistLastAttribute)) + } + + persistedAttribute, ok := attributes["persisted"] + + if !ok { + diags.AddError( + "Attribute Missing", + `persisted is missing from object`) + + return NewNamespacesValueUnknown(), diags + } + + persistedVal, ok := persistedAttribute.(basetypes.BoolValue) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`persisted expected to be basetypes.BoolValue, was: %T`, persistedAttribute)) + } + + populateChannelRegistryAttribute, ok := attributes["populate_channel_registry"] + + if !ok { + diags.AddError( + "Attribute Missing", + `populate_channel_registry is missing from object`) + + return NewNamespacesValueUnknown(), diags + } + + populateChannelRegistryVal, ok := populateChannelRegistryAttribute.(basetypes.BoolValue) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`populate_channel_registry expected to be basetypes.BoolValue, was: %T`, populateChannelRegistryAttribute)) + } + + pushEnabledAttribute, ok := attributes["push_enabled"] + + if !ok { + diags.AddError( + "Attribute Missing", + `push_enabled is missing from object`) + + return NewNamespacesValueUnknown(), diags + } + + pushEnabledVal, ok := pushEnabledAttribute.(basetypes.BoolValue) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`push_enabled expected to be basetypes.BoolValue, was: %T`, pushEnabledAttribute)) + } + + tlsOnlyAttribute, ok := attributes["tls_only"] + + if !ok { + diags.AddError( + "Attribute Missing", + `tls_only is missing from object`) + + return NewNamespacesValueUnknown(), diags + } + + tlsOnlyVal, ok := tlsOnlyAttribute.(basetypes.BoolValue) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`tls_only expected to be basetypes.BoolValue, was: %T`, tlsOnlyAttribute)) + } + + if diags.HasError() { + return NewNamespacesValueUnknown(), diags + } + + return NamespacesValue{ + AppId: appIdVal, + Authenticated: authenticatedVal, + BatchingEnabled: batchingEnabledVal, + BatchingInterval: batchingIntervalVal, + ConflationEnabled: conflationEnabledVal, + ConflationInterval: conflationIntervalVal, + ConflationKey: conflationKeyVal, + Created: createdVal, + ExposeTimeserial: exposeTimeserialVal, + Id: idVal, + Modified: modifiedVal, + MutableMessages: mutableMessagesVal, + PersistLast: persistLastVal, + Persisted: persistedVal, + PopulateChannelRegistry: populateChannelRegistryVal, + PushEnabled: pushEnabledVal, + TlsOnly: tlsOnlyVal, + state: attr.ValueStateKnown, + }, diags +} + +func NewNamespacesValueMust(attributeTypes map[string]attr.Type, attributes map[string]attr.Value) NamespacesValue { + object, diags := NewNamespacesValue(attributeTypes, attributes) + + if diags.HasError() { + // This could potentially be added to the diag package. + diagsStrings := make([]string, 0, len(diags)) + + for _, diagnostic := range diags { + diagsStrings = append(diagsStrings, fmt.Sprintf( + "%s | %s | %s", + diagnostic.Severity(), + diagnostic.Summary(), + diagnostic.Detail())) + } + + panic("NewNamespacesValueMust received error(s): " + strings.Join(diagsStrings, "\n")) + } + + return object +} + +func (t NamespacesType) ValueFromTerraform(ctx context.Context, in tftypes.Value) (attr.Value, error) { + if in.Type() == nil { + return NewNamespacesValueNull(), nil + } + + if !in.Type().Equal(t.TerraformType(ctx)) { + return nil, fmt.Errorf("expected %s, got %s", t.TerraformType(ctx), in.Type()) + } + + if !in.IsKnown() { + return NewNamespacesValueUnknown(), nil + } + + if in.IsNull() { + return NewNamespacesValueNull(), nil + } + + attributes := map[string]attr.Value{} + + val := map[string]tftypes.Value{} + + err := in.As(&val) + + if err != nil { + return nil, err + } + + for k, v := range val { + a, err := t.AttrTypes[k].ValueFromTerraform(ctx, v) + + if err != nil { + return nil, err + } + + attributes[k] = a + } + + return NewNamespacesValueMust(NamespacesValue{}.AttributeTypes(ctx), attributes), nil +} + +func (t NamespacesType) ValueType(ctx context.Context) attr.Value { + return NamespacesValue{} +} + +var _ basetypes.ObjectValuable = NamespacesValue{} + +type NamespacesValue struct { + AppId basetypes.StringValue `tfsdk:"app_id"` + Authenticated basetypes.BoolValue `tfsdk:"authenticated"` + BatchingEnabled basetypes.BoolValue `tfsdk:"batching_enabled"` + BatchingInterval basetypes.Int64Value `tfsdk:"batching_interval"` + ConflationEnabled basetypes.BoolValue `tfsdk:"conflation_enabled"` + ConflationInterval basetypes.Int64Value `tfsdk:"conflation_interval"` + ConflationKey basetypes.StringValue `tfsdk:"conflation_key"` + Created basetypes.Int64Value `tfsdk:"created"` + ExposeTimeserial basetypes.BoolValue `tfsdk:"expose_timeserial"` + Id basetypes.StringValue `tfsdk:"id"` + Modified basetypes.Int64Value `tfsdk:"modified"` + MutableMessages basetypes.BoolValue `tfsdk:"mutable_messages"` + PersistLast basetypes.BoolValue `tfsdk:"persist_last"` + Persisted basetypes.BoolValue `tfsdk:"persisted"` + PopulateChannelRegistry basetypes.BoolValue `tfsdk:"populate_channel_registry"` + PushEnabled basetypes.BoolValue `tfsdk:"push_enabled"` + TlsOnly basetypes.BoolValue `tfsdk:"tls_only"` + state attr.ValueState +} + +func (v NamespacesValue) ToTerraformValue(ctx context.Context) (tftypes.Value, error) { + attrTypes := make(map[string]tftypes.Type, 17) + + var val tftypes.Value + var err error + + attrTypes["app_id"] = basetypes.StringType{}.TerraformType(ctx) + attrTypes["authenticated"] = basetypes.BoolType{}.TerraformType(ctx) + attrTypes["batching_enabled"] = basetypes.BoolType{}.TerraformType(ctx) + attrTypes["batching_interval"] = basetypes.Int64Type{}.TerraformType(ctx) + attrTypes["conflation_enabled"] = basetypes.BoolType{}.TerraformType(ctx) + attrTypes["conflation_interval"] = basetypes.Int64Type{}.TerraformType(ctx) + attrTypes["conflation_key"] = basetypes.StringType{}.TerraformType(ctx) + attrTypes["created"] = basetypes.Int64Type{}.TerraformType(ctx) + attrTypes["expose_timeserial"] = basetypes.BoolType{}.TerraformType(ctx) + attrTypes["id"] = basetypes.StringType{}.TerraformType(ctx) + attrTypes["modified"] = basetypes.Int64Type{}.TerraformType(ctx) + attrTypes["mutable_messages"] = basetypes.BoolType{}.TerraformType(ctx) + attrTypes["persist_last"] = basetypes.BoolType{}.TerraformType(ctx) + attrTypes["persisted"] = basetypes.BoolType{}.TerraformType(ctx) + attrTypes["populate_channel_registry"] = basetypes.BoolType{}.TerraformType(ctx) + attrTypes["push_enabled"] = basetypes.BoolType{}.TerraformType(ctx) + attrTypes["tls_only"] = basetypes.BoolType{}.TerraformType(ctx) + + objectType := tftypes.Object{AttributeTypes: attrTypes} + + switch v.state { + case attr.ValueStateKnown: + vals := make(map[string]tftypes.Value, 17) + + val, err = v.AppId.ToTerraformValue(ctx) + + if err != nil { + return tftypes.NewValue(objectType, tftypes.UnknownValue), err + } + + vals["app_id"] = val + + val, err = v.Authenticated.ToTerraformValue(ctx) + + if err != nil { + return tftypes.NewValue(objectType, tftypes.UnknownValue), err + } + + vals["authenticated"] = val + + val, err = v.BatchingEnabled.ToTerraformValue(ctx) + + if err != nil { + return tftypes.NewValue(objectType, tftypes.UnknownValue), err + } + + vals["batching_enabled"] = val + + val, err = v.BatchingInterval.ToTerraformValue(ctx) + + if err != nil { + return tftypes.NewValue(objectType, tftypes.UnknownValue), err + } + + vals["batching_interval"] = val + + val, err = v.ConflationEnabled.ToTerraformValue(ctx) + + if err != nil { + return tftypes.NewValue(objectType, tftypes.UnknownValue), err + } + + vals["conflation_enabled"] = val + + val, err = v.ConflationInterval.ToTerraformValue(ctx) + + if err != nil { + return tftypes.NewValue(objectType, tftypes.UnknownValue), err + } + + vals["conflation_interval"] = val + + val, err = v.ConflationKey.ToTerraformValue(ctx) + + if err != nil { + return tftypes.NewValue(objectType, tftypes.UnknownValue), err + } + + vals["conflation_key"] = val + + val, err = v.Created.ToTerraformValue(ctx) + + if err != nil { + return tftypes.NewValue(objectType, tftypes.UnknownValue), err + } + + vals["created"] = val + + val, err = v.ExposeTimeserial.ToTerraformValue(ctx) + + if err != nil { + return tftypes.NewValue(objectType, tftypes.UnknownValue), err + } + + vals["expose_timeserial"] = val + + val, err = v.Id.ToTerraformValue(ctx) + + if err != nil { + return tftypes.NewValue(objectType, tftypes.UnknownValue), err + } + + vals["id"] = val + + val, err = v.Modified.ToTerraformValue(ctx) + + if err != nil { + return tftypes.NewValue(objectType, tftypes.UnknownValue), err + } + + vals["modified"] = val + + val, err = v.MutableMessages.ToTerraformValue(ctx) + + if err != nil { + return tftypes.NewValue(objectType, tftypes.UnknownValue), err + } + + vals["mutable_messages"] = val + + val, err = v.PersistLast.ToTerraformValue(ctx) + + if err != nil { + return tftypes.NewValue(objectType, tftypes.UnknownValue), err + } + + vals["persist_last"] = val + + val, err = v.Persisted.ToTerraformValue(ctx) + + if err != nil { + return tftypes.NewValue(objectType, tftypes.UnknownValue), err + } + + vals["persisted"] = val + + val, err = v.PopulateChannelRegistry.ToTerraformValue(ctx) + + if err != nil { + return tftypes.NewValue(objectType, tftypes.UnknownValue), err + } + + vals["populate_channel_registry"] = val + + val, err = v.PushEnabled.ToTerraformValue(ctx) + + if err != nil { + return tftypes.NewValue(objectType, tftypes.UnknownValue), err + } + + vals["push_enabled"] = val + + val, err = v.TlsOnly.ToTerraformValue(ctx) + + if err != nil { + return tftypes.NewValue(objectType, tftypes.UnknownValue), err + } + + vals["tls_only"] = val + + if err := tftypes.ValidateValue(objectType, vals); err != nil { + return tftypes.NewValue(objectType, tftypes.UnknownValue), err + } + + return tftypes.NewValue(objectType, vals), nil + case attr.ValueStateNull: + return tftypes.NewValue(objectType, nil), nil + case attr.ValueStateUnknown: + return tftypes.NewValue(objectType, tftypes.UnknownValue), nil + default: + panic(fmt.Sprintf("unhandled Object state in ToTerraformValue: %s", v.state)) + } +} + +func (v NamespacesValue) IsNull() bool { + return v.state == attr.ValueStateNull +} + +func (v NamespacesValue) IsUnknown() bool { + return v.state == attr.ValueStateUnknown +} + +func (v NamespacesValue) String() string { + return "NamespacesValue" +} + +func (v NamespacesValue) ToObjectValue(ctx context.Context) (basetypes.ObjectValue, diag.Diagnostics) { + var diags diag.Diagnostics + + attributeTypes := map[string]attr.Type{ + "app_id": basetypes.StringType{}, + "authenticated": basetypes.BoolType{}, + "batching_enabled": basetypes.BoolType{}, + "batching_interval": basetypes.Int64Type{}, + "conflation_enabled": basetypes.BoolType{}, + "conflation_interval": basetypes.Int64Type{}, + "conflation_key": basetypes.StringType{}, + "created": basetypes.Int64Type{}, + "expose_timeserial": basetypes.BoolType{}, + "id": basetypes.StringType{}, + "modified": basetypes.Int64Type{}, + "mutable_messages": basetypes.BoolType{}, + "persist_last": basetypes.BoolType{}, + "persisted": basetypes.BoolType{}, + "populate_channel_registry": basetypes.BoolType{}, + "push_enabled": basetypes.BoolType{}, + "tls_only": basetypes.BoolType{}, + } + + if v.IsNull() { + return types.ObjectNull(attributeTypes), diags + } + + if v.IsUnknown() { + return types.ObjectUnknown(attributeTypes), diags + } + + objVal, diags := types.ObjectValue( + attributeTypes, + map[string]attr.Value{ + "app_id": v.AppId, + "authenticated": v.Authenticated, + "batching_enabled": v.BatchingEnabled, + "batching_interval": v.BatchingInterval, + "conflation_enabled": v.ConflationEnabled, + "conflation_interval": v.ConflationInterval, + "conflation_key": v.ConflationKey, + "created": v.Created, + "expose_timeserial": v.ExposeTimeserial, + "id": v.Id, + "modified": v.Modified, + "mutable_messages": v.MutableMessages, + "persist_last": v.PersistLast, + "persisted": v.Persisted, + "populate_channel_registry": v.PopulateChannelRegistry, + "push_enabled": v.PushEnabled, + "tls_only": v.TlsOnly, + }) + + return objVal, diags +} + +func (v NamespacesValue) Equal(o attr.Value) bool { + other, ok := o.(NamespacesValue) + + if !ok { + return false + } + + if v.state != other.state { + return false + } + + if v.state != attr.ValueStateKnown { + return true + } + + if !v.AppId.Equal(other.AppId) { + return false + } + + if !v.Authenticated.Equal(other.Authenticated) { + return false + } + + if !v.BatchingEnabled.Equal(other.BatchingEnabled) { + return false + } + + if !v.BatchingInterval.Equal(other.BatchingInterval) { + return false + } + + if !v.ConflationEnabled.Equal(other.ConflationEnabled) { + return false + } + + if !v.ConflationInterval.Equal(other.ConflationInterval) { + return false + } + + if !v.ConflationKey.Equal(other.ConflationKey) { + return false + } + + if !v.Created.Equal(other.Created) { + return false + } + + if !v.ExposeTimeserial.Equal(other.ExposeTimeserial) { + return false + } + + if !v.Id.Equal(other.Id) { + return false + } + + if !v.Modified.Equal(other.Modified) { + return false + } + + if !v.MutableMessages.Equal(other.MutableMessages) { + return false + } + + if !v.PersistLast.Equal(other.PersistLast) { + return false + } + + if !v.Persisted.Equal(other.Persisted) { + return false + } + + if !v.PopulateChannelRegistry.Equal(other.PopulateChannelRegistry) { + return false + } + + if !v.PushEnabled.Equal(other.PushEnabled) { + return false + } + + if !v.TlsOnly.Equal(other.TlsOnly) { + return false + } + + return true +} + +func (v NamespacesValue) Type(ctx context.Context) attr.Type { + return NamespacesType{ + basetypes.ObjectType{ + AttrTypes: v.AttributeTypes(ctx), + }, + } +} + +func (v NamespacesValue) AttributeTypes(ctx context.Context) map[string]attr.Type { + return map[string]attr.Type{ + "app_id": basetypes.StringType{}, + "authenticated": basetypes.BoolType{}, + "batching_enabled": basetypes.BoolType{}, + "batching_interval": basetypes.Int64Type{}, + "conflation_enabled": basetypes.BoolType{}, + "conflation_interval": basetypes.Int64Type{}, + "conflation_key": basetypes.StringType{}, + "created": basetypes.Int64Type{}, + "expose_timeserial": basetypes.BoolType{}, + "id": basetypes.StringType{}, + "modified": basetypes.Int64Type{}, + "mutable_messages": basetypes.BoolType{}, + "persist_last": basetypes.BoolType{}, + "persisted": basetypes.BoolType{}, + "populate_channel_registry": basetypes.BoolType{}, + "push_enabled": basetypes.BoolType{}, + "tls_only": basetypes.BoolType{}, + } +} diff --git a/internal/provider/codegen/datasource_queues/queues_data_source_gen.go b/internal/provider/codegen/datasource_queues/queues_data_source_gen.go new file mode 100644 index 0000000..f54933a --- /dev/null +++ b/internal/provider/codegen/datasource_queues/queues_data_source_gen.go @@ -0,0 +1,2960 @@ +// Code generated by terraform-plugin-framework-generator DO NOT EDIT. + +package datasource_queues + +import ( + "context" + "fmt" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" + "github.com/hashicorp/terraform-plugin-go/tftypes" + "strings" + + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" +) + +func QueuesDataSourceSchema(ctx context.Context) schema.Schema { + return schema.Schema{ + Attributes: map[string]schema.Attribute{ + "app_id": schema.StringAttribute{ + Required: true, + Description: "The application ID.", + MarkdownDescription: "The application ID.", + }, + "queues": schema.SetNestedAttribute{ + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "amqp": schema.SingleNestedAttribute{ + Attributes: map[string]schema.Attribute{ + "queue_name": schema.StringAttribute{ + Computed: true, + Description: "Name of the Ably queue.", + MarkdownDescription: "Name of the Ably queue.", + }, + "uri": schema.StringAttribute{ + Computed: true, + Description: "URI for the AMQP queue interface.", + MarkdownDescription: "URI for the AMQP queue interface.", + }, + }, + CustomType: AmqpType{ + ObjectType: types.ObjectType{ + AttrTypes: AmqpValue{}.AttributeTypes(ctx), + }, + }, + Computed: true, + }, + "app_id": schema.StringAttribute{ + Computed: true, + Description: "The Ably application ID.", + MarkdownDescription: "The Ably application ID.", + }, + "deadletter": schema.BoolAttribute{ + Computed: true, + Description: "A boolean that indicates whether this is a dead letter queue or not.", + MarkdownDescription: "A boolean that indicates whether this is a dead letter queue or not.", + }, + "deadletter_id": schema.StringAttribute{ + Computed: true, + Description: "The ID of the dead letter queue.", + MarkdownDescription: "The ID of the dead letter queue.", + }, + "id": schema.StringAttribute{ + Computed: true, + Description: "The ID of the Ably queue", + MarkdownDescription: "The ID of the Ably queue", + }, + "max_length": schema.Int64Attribute{ + Computed: true, + Description: "Message limit in number of messages.", + MarkdownDescription: "Message limit in number of messages.", + }, + "messages": schema.SingleNestedAttribute{ + Attributes: map[string]schema.Attribute{ + "ready": schema.Int64Attribute{ + Computed: true, + Description: "The number of ready messages in the queue.", + MarkdownDescription: "The number of ready messages in the queue.", + }, + "total": schema.Int64Attribute{ + Computed: true, + Description: "The total number of messages in the queue.", + MarkdownDescription: "The total number of messages in the queue.", + }, + "unacknowledged": schema.Int64Attribute{ + Computed: true, + Description: "The number of unacknowledged messages in the queue.", + MarkdownDescription: "The number of unacknowledged messages in the queue.", + }, + }, + CustomType: MessagesType{ + ObjectType: types.ObjectType{ + AttrTypes: MessagesValue{}.AttributeTypes(ctx), + }, + }, + Computed: true, + Description: "Details of messages in the queue.", + MarkdownDescription: "Details of messages in the queue.", + }, + "name": schema.StringAttribute{ + Computed: true, + Description: "The friendly name of the queue.", + MarkdownDescription: "The friendly name of the queue.", + }, + "region": schema.StringAttribute{ + Computed: true, + Description: "The data center region for the queue.", + MarkdownDescription: "The data center region for the queue.", + }, + "state": schema.StringAttribute{ + Computed: true, + Description: "The current state of the queue.", + MarkdownDescription: "The current state of the queue.", + }, + "stats": schema.SingleNestedAttribute{ + Attributes: map[string]schema.Attribute{ + "acknowledgement_rate": schema.NumberAttribute{ + Computed: true, + Description: "The rate at which messages are acknowledged. Rate is messages per minute.", + MarkdownDescription: "The rate at which messages are acknowledged. Rate is messages per minute.", + }, + "delivery_rate": schema.NumberAttribute{ + Computed: true, + Description: "The rate at which messages are delivered from the queue. Rate is messages per minute.", + MarkdownDescription: "The rate at which messages are delivered from the queue. Rate is messages per minute.", + }, + "publish_rate": schema.NumberAttribute{ + Computed: true, + Description: "The rate at which messages are published to the queue. Rate is messages per minute.", + MarkdownDescription: "The rate at which messages are published to the queue. Rate is messages per minute.", + }, + }, + CustomType: StatsType{ + ObjectType: types.ObjectType{ + AttrTypes: StatsValue{}.AttributeTypes(ctx), + }, + }, + Computed: true, + }, + "stomp": schema.SingleNestedAttribute{ + Attributes: map[string]schema.Attribute{ + "destination": schema.StringAttribute{ + Computed: true, + Description: "Destination queue.", + MarkdownDescription: "Destination queue.", + }, + "host": schema.StringAttribute{ + Computed: true, + Description: "The host type for the queue.", + MarkdownDescription: "The host type for the queue.", + }, + "uri": schema.StringAttribute{ + Computed: true, + Description: "URI for the STOMP queue interface.", + MarkdownDescription: "URI for the STOMP queue interface.", + }, + }, + CustomType: StompType{ + ObjectType: types.ObjectType{ + AttrTypes: StompValue{}.AttributeTypes(ctx), + }, + }, + Computed: true, + }, + "ttl": schema.Int64Attribute{ + Computed: true, + Description: "TTL in minutes.", + MarkdownDescription: "TTL in minutes.", + }, + }, + CustomType: QueuesType{ + ObjectType: types.ObjectType{ + AttrTypes: QueuesValue{}.AttributeTypes(ctx), + }, + }, + }, + Computed: true, + }, + }, + } +} + +type QueuesModel struct { + AppId types.String `tfsdk:"app_id"` + Queues types.Set `tfsdk:"queues"` +} + +var _ basetypes.ObjectTypable = QueuesType{} + +type QueuesType struct { + basetypes.ObjectType +} + +func (t QueuesType) Equal(o attr.Type) bool { + other, ok := o.(QueuesType) + + if !ok { + return false + } + + return t.ObjectType.Equal(other.ObjectType) +} + +func (t QueuesType) String() string { + return "QueuesType" +} + +func (t QueuesType) ValueFromObject(ctx context.Context, in basetypes.ObjectValue) (basetypes.ObjectValuable, diag.Diagnostics) { + var diags diag.Diagnostics + + attributes := in.Attributes() + + amqpAttribute, ok := attributes["amqp"] + + if !ok { + diags.AddError( + "Attribute Missing", + `amqp is missing from object`) + + return nil, diags + } + + amqpVal, ok := amqpAttribute.(basetypes.ObjectValue) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`amqp expected to be basetypes.ObjectValue, was: %T`, amqpAttribute)) + } + + appIdAttribute, ok := attributes["app_id"] + + if !ok { + diags.AddError( + "Attribute Missing", + `app_id is missing from object`) + + return nil, diags + } + + appIdVal, ok := appIdAttribute.(basetypes.StringValue) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`app_id expected to be basetypes.StringValue, was: %T`, appIdAttribute)) + } + + deadletterAttribute, ok := attributes["deadletter"] + + if !ok { + diags.AddError( + "Attribute Missing", + `deadletter is missing from object`) + + return nil, diags + } + + deadletterVal, ok := deadletterAttribute.(basetypes.BoolValue) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`deadletter expected to be basetypes.BoolValue, was: %T`, deadletterAttribute)) + } + + deadletterIdAttribute, ok := attributes["deadletter_id"] + + if !ok { + diags.AddError( + "Attribute Missing", + `deadletter_id is missing from object`) + + return nil, diags + } + + deadletterIdVal, ok := deadletterIdAttribute.(basetypes.StringValue) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`deadletter_id expected to be basetypes.StringValue, was: %T`, deadletterIdAttribute)) + } + + idAttribute, ok := attributes["id"] + + if !ok { + diags.AddError( + "Attribute Missing", + `id is missing from object`) + + return nil, diags + } + + idVal, ok := idAttribute.(basetypes.StringValue) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`id expected to be basetypes.StringValue, was: %T`, idAttribute)) + } + + maxLengthAttribute, ok := attributes["max_length"] + + if !ok { + diags.AddError( + "Attribute Missing", + `max_length is missing from object`) + + return nil, diags + } + + maxLengthVal, ok := maxLengthAttribute.(basetypes.Int64Value) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`max_length expected to be basetypes.Int64Value, was: %T`, maxLengthAttribute)) + } + + messagesAttribute, ok := attributes["messages"] + + if !ok { + diags.AddError( + "Attribute Missing", + `messages is missing from object`) + + return nil, diags + } + + messagesVal, ok := messagesAttribute.(basetypes.ObjectValue) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`messages expected to be basetypes.ObjectValue, was: %T`, messagesAttribute)) + } + + nameAttribute, ok := attributes["name"] + + if !ok { + diags.AddError( + "Attribute Missing", + `name is missing from object`) + + return nil, diags + } + + nameVal, ok := nameAttribute.(basetypes.StringValue) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`name expected to be basetypes.StringValue, was: %T`, nameAttribute)) + } + + regionAttribute, ok := attributes["region"] + + if !ok { + diags.AddError( + "Attribute Missing", + `region is missing from object`) + + return nil, diags + } + + regionVal, ok := regionAttribute.(basetypes.StringValue) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`region expected to be basetypes.StringValue, was: %T`, regionAttribute)) + } + + stateAttribute, ok := attributes["state"] + + if !ok { + diags.AddError( + "Attribute Missing", + `state is missing from object`) + + return nil, diags + } + + stateVal, ok := stateAttribute.(basetypes.StringValue) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`state expected to be basetypes.StringValue, was: %T`, stateAttribute)) + } + + statsAttribute, ok := attributes["stats"] + + if !ok { + diags.AddError( + "Attribute Missing", + `stats is missing from object`) + + return nil, diags + } + + statsVal, ok := statsAttribute.(basetypes.ObjectValue) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`stats expected to be basetypes.ObjectValue, was: %T`, statsAttribute)) + } + + stompAttribute, ok := attributes["stomp"] + + if !ok { + diags.AddError( + "Attribute Missing", + `stomp is missing from object`) + + return nil, diags + } + + stompVal, ok := stompAttribute.(basetypes.ObjectValue) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`stomp expected to be basetypes.ObjectValue, was: %T`, stompAttribute)) + } + + ttlAttribute, ok := attributes["ttl"] + + if !ok { + diags.AddError( + "Attribute Missing", + `ttl is missing from object`) + + return nil, diags + } + + ttlVal, ok := ttlAttribute.(basetypes.Int64Value) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`ttl expected to be basetypes.Int64Value, was: %T`, ttlAttribute)) + } + + if diags.HasError() { + return nil, diags + } + + return QueuesValue{ + Amqp: amqpVal, + AppId: appIdVal, + Deadletter: deadletterVal, + DeadletterId: deadletterIdVal, + Id: idVal, + MaxLength: maxLengthVal, + Messages: messagesVal, + Name: nameVal, + Region: regionVal, + State: stateVal, + Stats: statsVal, + Stomp: stompVal, + Ttl: ttlVal, + state: attr.ValueStateKnown, + }, diags +} + +func NewQueuesValueNull() QueuesValue { + return QueuesValue{ + state: attr.ValueStateNull, + } +} + +func NewQueuesValueUnknown() QueuesValue { + return QueuesValue{ + state: attr.ValueStateUnknown, + } +} + +func NewQueuesValue(attributeTypes map[string]attr.Type, attributes map[string]attr.Value) (QueuesValue, diag.Diagnostics) { + var diags diag.Diagnostics + + // Reference: https://github.com/hashicorp/terraform-plugin-framework/issues/521 + ctx := context.Background() + + for name, attributeType := range attributeTypes { + attribute, ok := attributes[name] + + if !ok { + diags.AddError( + "Missing QueuesValue Attribute Value", + "While creating a QueuesValue value, a missing attribute value was detected. "+ + "A QueuesValue must contain values for all attributes, even if null or unknown. "+ + "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ + fmt.Sprintf("QueuesValue Attribute Name (%s) Expected Type: %s", name, attributeType.String()), + ) + + continue + } + + if !attributeType.Equal(attribute.Type(ctx)) { + diags.AddError( + "Invalid QueuesValue Attribute Type", + "While creating a QueuesValue value, an invalid attribute value was detected. "+ + "A QueuesValue must use a matching attribute type for the value. "+ + "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ + fmt.Sprintf("QueuesValue Attribute Name (%s) Expected Type: %s\n", name, attributeType.String())+ + fmt.Sprintf("QueuesValue Attribute Name (%s) Given Type: %s", name, attribute.Type(ctx)), + ) + } + } + + for name := range attributes { + _, ok := attributeTypes[name] + + if !ok { + diags.AddError( + "Extra QueuesValue Attribute Value", + "While creating a QueuesValue value, an extra attribute value was detected. "+ + "A QueuesValue must not contain values beyond the expected attribute types. "+ + "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ + fmt.Sprintf("Extra QueuesValue Attribute Name: %s", name), + ) + } + } + + if diags.HasError() { + return NewQueuesValueUnknown(), diags + } + + amqpAttribute, ok := attributes["amqp"] + + if !ok { + diags.AddError( + "Attribute Missing", + `amqp is missing from object`) + + return NewQueuesValueUnknown(), diags + } + + amqpVal, ok := amqpAttribute.(basetypes.ObjectValue) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`amqp expected to be basetypes.ObjectValue, was: %T`, amqpAttribute)) + } + + appIdAttribute, ok := attributes["app_id"] + + if !ok { + diags.AddError( + "Attribute Missing", + `app_id is missing from object`) + + return NewQueuesValueUnknown(), diags + } + + appIdVal, ok := appIdAttribute.(basetypes.StringValue) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`app_id expected to be basetypes.StringValue, was: %T`, appIdAttribute)) + } + + deadletterAttribute, ok := attributes["deadletter"] + + if !ok { + diags.AddError( + "Attribute Missing", + `deadletter is missing from object`) + + return NewQueuesValueUnknown(), diags + } + + deadletterVal, ok := deadletterAttribute.(basetypes.BoolValue) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`deadletter expected to be basetypes.BoolValue, was: %T`, deadletterAttribute)) + } + + deadletterIdAttribute, ok := attributes["deadletter_id"] + + if !ok { + diags.AddError( + "Attribute Missing", + `deadletter_id is missing from object`) + + return NewQueuesValueUnknown(), diags + } + + deadletterIdVal, ok := deadletterIdAttribute.(basetypes.StringValue) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`deadletter_id expected to be basetypes.StringValue, was: %T`, deadletterIdAttribute)) + } + + idAttribute, ok := attributes["id"] + + if !ok { + diags.AddError( + "Attribute Missing", + `id is missing from object`) + + return NewQueuesValueUnknown(), diags + } + + idVal, ok := idAttribute.(basetypes.StringValue) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`id expected to be basetypes.StringValue, was: %T`, idAttribute)) + } + + maxLengthAttribute, ok := attributes["max_length"] + + if !ok { + diags.AddError( + "Attribute Missing", + `max_length is missing from object`) + + return NewQueuesValueUnknown(), diags + } + + maxLengthVal, ok := maxLengthAttribute.(basetypes.Int64Value) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`max_length expected to be basetypes.Int64Value, was: %T`, maxLengthAttribute)) + } + + messagesAttribute, ok := attributes["messages"] + + if !ok { + diags.AddError( + "Attribute Missing", + `messages is missing from object`) + + return NewQueuesValueUnknown(), diags + } + + messagesVal, ok := messagesAttribute.(basetypes.ObjectValue) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`messages expected to be basetypes.ObjectValue, was: %T`, messagesAttribute)) + } + + nameAttribute, ok := attributes["name"] + + if !ok { + diags.AddError( + "Attribute Missing", + `name is missing from object`) + + return NewQueuesValueUnknown(), diags + } + + nameVal, ok := nameAttribute.(basetypes.StringValue) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`name expected to be basetypes.StringValue, was: %T`, nameAttribute)) + } + + regionAttribute, ok := attributes["region"] + + if !ok { + diags.AddError( + "Attribute Missing", + `region is missing from object`) + + return NewQueuesValueUnknown(), diags + } + + regionVal, ok := regionAttribute.(basetypes.StringValue) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`region expected to be basetypes.StringValue, was: %T`, regionAttribute)) + } + + stateAttribute, ok := attributes["state"] + + if !ok { + diags.AddError( + "Attribute Missing", + `state is missing from object`) + + return NewQueuesValueUnknown(), diags + } + + stateVal, ok := stateAttribute.(basetypes.StringValue) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`state expected to be basetypes.StringValue, was: %T`, stateAttribute)) + } + + statsAttribute, ok := attributes["stats"] + + if !ok { + diags.AddError( + "Attribute Missing", + `stats is missing from object`) + + return NewQueuesValueUnknown(), diags + } + + statsVal, ok := statsAttribute.(basetypes.ObjectValue) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`stats expected to be basetypes.ObjectValue, was: %T`, statsAttribute)) + } + + stompAttribute, ok := attributes["stomp"] + + if !ok { + diags.AddError( + "Attribute Missing", + `stomp is missing from object`) + + return NewQueuesValueUnknown(), diags + } + + stompVal, ok := stompAttribute.(basetypes.ObjectValue) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`stomp expected to be basetypes.ObjectValue, was: %T`, stompAttribute)) + } + + ttlAttribute, ok := attributes["ttl"] + + if !ok { + diags.AddError( + "Attribute Missing", + `ttl is missing from object`) + + return NewQueuesValueUnknown(), diags + } + + ttlVal, ok := ttlAttribute.(basetypes.Int64Value) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`ttl expected to be basetypes.Int64Value, was: %T`, ttlAttribute)) + } + + if diags.HasError() { + return NewQueuesValueUnknown(), diags + } + + return QueuesValue{ + Amqp: amqpVal, + AppId: appIdVal, + Deadletter: deadletterVal, + DeadletterId: deadletterIdVal, + Id: idVal, + MaxLength: maxLengthVal, + Messages: messagesVal, + Name: nameVal, + Region: regionVal, + State: stateVal, + Stats: statsVal, + Stomp: stompVal, + Ttl: ttlVal, + state: attr.ValueStateKnown, + }, diags +} + +func NewQueuesValueMust(attributeTypes map[string]attr.Type, attributes map[string]attr.Value) QueuesValue { + object, diags := NewQueuesValue(attributeTypes, attributes) + + if diags.HasError() { + // This could potentially be added to the diag package. + diagsStrings := make([]string, 0, len(diags)) + + for _, diagnostic := range diags { + diagsStrings = append(diagsStrings, fmt.Sprintf( + "%s | %s | %s", + diagnostic.Severity(), + diagnostic.Summary(), + diagnostic.Detail())) + } + + panic("NewQueuesValueMust received error(s): " + strings.Join(diagsStrings, "\n")) + } + + return object +} + +func (t QueuesType) ValueFromTerraform(ctx context.Context, in tftypes.Value) (attr.Value, error) { + if in.Type() == nil { + return NewQueuesValueNull(), nil + } + + if !in.Type().Equal(t.TerraformType(ctx)) { + return nil, fmt.Errorf("expected %s, got %s", t.TerraformType(ctx), in.Type()) + } + + if !in.IsKnown() { + return NewQueuesValueUnknown(), nil + } + + if in.IsNull() { + return NewQueuesValueNull(), nil + } + + attributes := map[string]attr.Value{} + + val := map[string]tftypes.Value{} + + err := in.As(&val) + + if err != nil { + return nil, err + } + + for k, v := range val { + a, err := t.AttrTypes[k].ValueFromTerraform(ctx, v) + + if err != nil { + return nil, err + } + + attributes[k] = a + } + + return NewQueuesValueMust(QueuesValue{}.AttributeTypes(ctx), attributes), nil +} + +func (t QueuesType) ValueType(ctx context.Context) attr.Value { + return QueuesValue{} +} + +var _ basetypes.ObjectValuable = QueuesValue{} + +type QueuesValue struct { + Amqp basetypes.ObjectValue `tfsdk:"amqp"` + AppId basetypes.StringValue `tfsdk:"app_id"` + Deadletter basetypes.BoolValue `tfsdk:"deadletter"` + DeadletterId basetypes.StringValue `tfsdk:"deadletter_id"` + Id basetypes.StringValue `tfsdk:"id"` + MaxLength basetypes.Int64Value `tfsdk:"max_length"` + Messages basetypes.ObjectValue `tfsdk:"messages"` + Name basetypes.StringValue `tfsdk:"name"` + Region basetypes.StringValue `tfsdk:"region"` + State basetypes.StringValue `tfsdk:"state"` + Stats basetypes.ObjectValue `tfsdk:"stats"` + Stomp basetypes.ObjectValue `tfsdk:"stomp"` + Ttl basetypes.Int64Value `tfsdk:"ttl"` + state attr.ValueState +} + +func (v QueuesValue) ToTerraformValue(ctx context.Context) (tftypes.Value, error) { + attrTypes := make(map[string]tftypes.Type, 13) + + var val tftypes.Value + var err error + + attrTypes["amqp"] = basetypes.ObjectType{ + AttrTypes: AmqpValue{}.AttributeTypes(ctx), + }.TerraformType(ctx) + attrTypes["app_id"] = basetypes.StringType{}.TerraformType(ctx) + attrTypes["deadletter"] = basetypes.BoolType{}.TerraformType(ctx) + attrTypes["deadletter_id"] = basetypes.StringType{}.TerraformType(ctx) + attrTypes["id"] = basetypes.StringType{}.TerraformType(ctx) + attrTypes["max_length"] = basetypes.Int64Type{}.TerraformType(ctx) + attrTypes["messages"] = basetypes.ObjectType{ + AttrTypes: MessagesValue{}.AttributeTypes(ctx), + }.TerraformType(ctx) + attrTypes["name"] = basetypes.StringType{}.TerraformType(ctx) + attrTypes["region"] = basetypes.StringType{}.TerraformType(ctx) + attrTypes["state"] = basetypes.StringType{}.TerraformType(ctx) + attrTypes["stats"] = basetypes.ObjectType{ + AttrTypes: StatsValue{}.AttributeTypes(ctx), + }.TerraformType(ctx) + attrTypes["stomp"] = basetypes.ObjectType{ + AttrTypes: StompValue{}.AttributeTypes(ctx), + }.TerraformType(ctx) + attrTypes["ttl"] = basetypes.Int64Type{}.TerraformType(ctx) + + objectType := tftypes.Object{AttributeTypes: attrTypes} + + switch v.state { + case attr.ValueStateKnown: + vals := make(map[string]tftypes.Value, 13) + + val, err = v.Amqp.ToTerraformValue(ctx) + + if err != nil { + return tftypes.NewValue(objectType, tftypes.UnknownValue), err + } + + vals["amqp"] = val + + val, err = v.AppId.ToTerraformValue(ctx) + + if err != nil { + return tftypes.NewValue(objectType, tftypes.UnknownValue), err + } + + vals["app_id"] = val + + val, err = v.Deadletter.ToTerraformValue(ctx) + + if err != nil { + return tftypes.NewValue(objectType, tftypes.UnknownValue), err + } + + vals["deadletter"] = val + + val, err = v.DeadletterId.ToTerraformValue(ctx) + + if err != nil { + return tftypes.NewValue(objectType, tftypes.UnknownValue), err + } + + vals["deadletter_id"] = val + + val, err = v.Id.ToTerraformValue(ctx) + + if err != nil { + return tftypes.NewValue(objectType, tftypes.UnknownValue), err + } + + vals["id"] = val + + val, err = v.MaxLength.ToTerraformValue(ctx) + + if err != nil { + return tftypes.NewValue(objectType, tftypes.UnknownValue), err + } + + vals["max_length"] = val + + val, err = v.Messages.ToTerraformValue(ctx) + + if err != nil { + return tftypes.NewValue(objectType, tftypes.UnknownValue), err + } + + vals["messages"] = val + + val, err = v.Name.ToTerraformValue(ctx) + + if err != nil { + return tftypes.NewValue(objectType, tftypes.UnknownValue), err + } + + vals["name"] = val + + val, err = v.Region.ToTerraformValue(ctx) + + if err != nil { + return tftypes.NewValue(objectType, tftypes.UnknownValue), err + } + + vals["region"] = val + + val, err = v.State.ToTerraformValue(ctx) + + if err != nil { + return tftypes.NewValue(objectType, tftypes.UnknownValue), err + } + + vals["state"] = val + + val, err = v.Stats.ToTerraformValue(ctx) + + if err != nil { + return tftypes.NewValue(objectType, tftypes.UnknownValue), err + } + + vals["stats"] = val + + val, err = v.Stomp.ToTerraformValue(ctx) + + if err != nil { + return tftypes.NewValue(objectType, tftypes.UnknownValue), err + } + + vals["stomp"] = val + + val, err = v.Ttl.ToTerraformValue(ctx) + + if err != nil { + return tftypes.NewValue(objectType, tftypes.UnknownValue), err + } + + vals["ttl"] = val + + if err := tftypes.ValidateValue(objectType, vals); err != nil { + return tftypes.NewValue(objectType, tftypes.UnknownValue), err + } + + return tftypes.NewValue(objectType, vals), nil + case attr.ValueStateNull: + return tftypes.NewValue(objectType, nil), nil + case attr.ValueStateUnknown: + return tftypes.NewValue(objectType, tftypes.UnknownValue), nil + default: + panic(fmt.Sprintf("unhandled Object state in ToTerraformValue: %s", v.state)) + } +} + +func (v QueuesValue) IsNull() bool { + return v.state == attr.ValueStateNull +} + +func (v QueuesValue) IsUnknown() bool { + return v.state == attr.ValueStateUnknown +} + +func (v QueuesValue) String() string { + return "QueuesValue" +} + +func (v QueuesValue) ToObjectValue(ctx context.Context) (basetypes.ObjectValue, diag.Diagnostics) { + var diags diag.Diagnostics + + var amqp basetypes.ObjectValue + + if v.Amqp.IsNull() { + amqp = types.ObjectNull( + AmqpValue{}.AttributeTypes(ctx), + ) + } + + if v.Amqp.IsUnknown() { + amqp = types.ObjectUnknown( + AmqpValue{}.AttributeTypes(ctx), + ) + } + + if !v.Amqp.IsNull() && !v.Amqp.IsUnknown() { + amqp = types.ObjectValueMust( + AmqpValue{}.AttributeTypes(ctx), + v.Amqp.Attributes(), + ) + } + + var messages basetypes.ObjectValue + + if v.Messages.IsNull() { + messages = types.ObjectNull( + MessagesValue{}.AttributeTypes(ctx), + ) + } + + if v.Messages.IsUnknown() { + messages = types.ObjectUnknown( + MessagesValue{}.AttributeTypes(ctx), + ) + } + + if !v.Messages.IsNull() && !v.Messages.IsUnknown() { + messages = types.ObjectValueMust( + MessagesValue{}.AttributeTypes(ctx), + v.Messages.Attributes(), + ) + } + + var stats basetypes.ObjectValue + + if v.Stats.IsNull() { + stats = types.ObjectNull( + StatsValue{}.AttributeTypes(ctx), + ) + } + + if v.Stats.IsUnknown() { + stats = types.ObjectUnknown( + StatsValue{}.AttributeTypes(ctx), + ) + } + + if !v.Stats.IsNull() && !v.Stats.IsUnknown() { + stats = types.ObjectValueMust( + StatsValue{}.AttributeTypes(ctx), + v.Stats.Attributes(), + ) + } + + var stomp basetypes.ObjectValue + + if v.Stomp.IsNull() { + stomp = types.ObjectNull( + StompValue{}.AttributeTypes(ctx), + ) + } + + if v.Stomp.IsUnknown() { + stomp = types.ObjectUnknown( + StompValue{}.AttributeTypes(ctx), + ) + } + + if !v.Stomp.IsNull() && !v.Stomp.IsUnknown() { + stomp = types.ObjectValueMust( + StompValue{}.AttributeTypes(ctx), + v.Stomp.Attributes(), + ) + } + + attributeTypes := map[string]attr.Type{ + "amqp": basetypes.ObjectType{ + AttrTypes: AmqpValue{}.AttributeTypes(ctx), + }, + "app_id": basetypes.StringType{}, + "deadletter": basetypes.BoolType{}, + "deadletter_id": basetypes.StringType{}, + "id": basetypes.StringType{}, + "max_length": basetypes.Int64Type{}, + "messages": basetypes.ObjectType{ + AttrTypes: MessagesValue{}.AttributeTypes(ctx), + }, + "name": basetypes.StringType{}, + "region": basetypes.StringType{}, + "state": basetypes.StringType{}, + "stats": basetypes.ObjectType{ + AttrTypes: StatsValue{}.AttributeTypes(ctx), + }, + "stomp": basetypes.ObjectType{ + AttrTypes: StompValue{}.AttributeTypes(ctx), + }, + "ttl": basetypes.Int64Type{}, + } + + if v.IsNull() { + return types.ObjectNull(attributeTypes), diags + } + + if v.IsUnknown() { + return types.ObjectUnknown(attributeTypes), diags + } + + objVal, diags := types.ObjectValue( + attributeTypes, + map[string]attr.Value{ + "amqp": amqp, + "app_id": v.AppId, + "deadletter": v.Deadletter, + "deadletter_id": v.DeadletterId, + "id": v.Id, + "max_length": v.MaxLength, + "messages": messages, + "name": v.Name, + "region": v.Region, + "state": v.State, + "stats": stats, + "stomp": stomp, + "ttl": v.Ttl, + }) + + return objVal, diags +} + +func (v QueuesValue) Equal(o attr.Value) bool { + other, ok := o.(QueuesValue) + + if !ok { + return false + } + + if v.state != other.state { + return false + } + + if v.state != attr.ValueStateKnown { + return true + } + + if !v.Amqp.Equal(other.Amqp) { + return false + } + + if !v.AppId.Equal(other.AppId) { + return false + } + + if !v.Deadletter.Equal(other.Deadletter) { + return false + } + + if !v.DeadletterId.Equal(other.DeadletterId) { + return false + } + + if !v.Id.Equal(other.Id) { + return false + } + + if !v.MaxLength.Equal(other.MaxLength) { + return false + } + + if !v.Messages.Equal(other.Messages) { + return false + } + + if !v.Name.Equal(other.Name) { + return false + } + + if !v.Region.Equal(other.Region) { + return false + } + + if !v.State.Equal(other.State) { + return false + } + + if !v.Stats.Equal(other.Stats) { + return false + } + + if !v.Stomp.Equal(other.Stomp) { + return false + } + + if !v.Ttl.Equal(other.Ttl) { + return false + } + + return true +} + +func (v QueuesValue) Type(ctx context.Context) attr.Type { + return QueuesType{ + basetypes.ObjectType{ + AttrTypes: v.AttributeTypes(ctx), + }, + } +} + +func (v QueuesValue) AttributeTypes(ctx context.Context) map[string]attr.Type { + return map[string]attr.Type{ + "amqp": basetypes.ObjectType{ + AttrTypes: AmqpValue{}.AttributeTypes(ctx), + }, + "app_id": basetypes.StringType{}, + "deadletter": basetypes.BoolType{}, + "deadletter_id": basetypes.StringType{}, + "id": basetypes.StringType{}, + "max_length": basetypes.Int64Type{}, + "messages": basetypes.ObjectType{ + AttrTypes: MessagesValue{}.AttributeTypes(ctx), + }, + "name": basetypes.StringType{}, + "region": basetypes.StringType{}, + "state": basetypes.StringType{}, + "stats": basetypes.ObjectType{ + AttrTypes: StatsValue{}.AttributeTypes(ctx), + }, + "stomp": basetypes.ObjectType{ + AttrTypes: StompValue{}.AttributeTypes(ctx), + }, + "ttl": basetypes.Int64Type{}, + } +} + +var _ basetypes.ObjectTypable = AmqpType{} + +type AmqpType struct { + basetypes.ObjectType +} + +func (t AmqpType) Equal(o attr.Type) bool { + other, ok := o.(AmqpType) + + if !ok { + return false + } + + return t.ObjectType.Equal(other.ObjectType) +} + +func (t AmqpType) String() string { + return "AmqpType" +} + +func (t AmqpType) ValueFromObject(ctx context.Context, in basetypes.ObjectValue) (basetypes.ObjectValuable, diag.Diagnostics) { + var diags diag.Diagnostics + + attributes := in.Attributes() + + queueNameAttribute, ok := attributes["queue_name"] + + if !ok { + diags.AddError( + "Attribute Missing", + `queue_name is missing from object`) + + return nil, diags + } + + queueNameVal, ok := queueNameAttribute.(basetypes.StringValue) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`queue_name expected to be basetypes.StringValue, was: %T`, queueNameAttribute)) + } + + uriAttribute, ok := attributes["uri"] + + if !ok { + diags.AddError( + "Attribute Missing", + `uri is missing from object`) + + return nil, diags + } + + uriVal, ok := uriAttribute.(basetypes.StringValue) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`uri expected to be basetypes.StringValue, was: %T`, uriAttribute)) + } + + if diags.HasError() { + return nil, diags + } + + return AmqpValue{ + QueueName: queueNameVal, + Uri: uriVal, + state: attr.ValueStateKnown, + }, diags +} + +func NewAmqpValueNull() AmqpValue { + return AmqpValue{ + state: attr.ValueStateNull, + } +} + +func NewAmqpValueUnknown() AmqpValue { + return AmqpValue{ + state: attr.ValueStateUnknown, + } +} + +func NewAmqpValue(attributeTypes map[string]attr.Type, attributes map[string]attr.Value) (AmqpValue, diag.Diagnostics) { + var diags diag.Diagnostics + + // Reference: https://github.com/hashicorp/terraform-plugin-framework/issues/521 + ctx := context.Background() + + for name, attributeType := range attributeTypes { + attribute, ok := attributes[name] + + if !ok { + diags.AddError( + "Missing AmqpValue Attribute Value", + "While creating a AmqpValue value, a missing attribute value was detected. "+ + "A AmqpValue must contain values for all attributes, even if null or unknown. "+ + "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ + fmt.Sprintf("AmqpValue Attribute Name (%s) Expected Type: %s", name, attributeType.String()), + ) + + continue + } + + if !attributeType.Equal(attribute.Type(ctx)) { + diags.AddError( + "Invalid AmqpValue Attribute Type", + "While creating a AmqpValue value, an invalid attribute value was detected. "+ + "A AmqpValue must use a matching attribute type for the value. "+ + "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ + fmt.Sprintf("AmqpValue Attribute Name (%s) Expected Type: %s\n", name, attributeType.String())+ + fmt.Sprintf("AmqpValue Attribute Name (%s) Given Type: %s", name, attribute.Type(ctx)), + ) + } + } + + for name := range attributes { + _, ok := attributeTypes[name] + + if !ok { + diags.AddError( + "Extra AmqpValue Attribute Value", + "While creating a AmqpValue value, an extra attribute value was detected. "+ + "A AmqpValue must not contain values beyond the expected attribute types. "+ + "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ + fmt.Sprintf("Extra AmqpValue Attribute Name: %s", name), + ) + } + } + + if diags.HasError() { + return NewAmqpValueUnknown(), diags + } + + queueNameAttribute, ok := attributes["queue_name"] + + if !ok { + diags.AddError( + "Attribute Missing", + `queue_name is missing from object`) + + return NewAmqpValueUnknown(), diags + } + + queueNameVal, ok := queueNameAttribute.(basetypes.StringValue) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`queue_name expected to be basetypes.StringValue, was: %T`, queueNameAttribute)) + } + + uriAttribute, ok := attributes["uri"] + + if !ok { + diags.AddError( + "Attribute Missing", + `uri is missing from object`) + + return NewAmqpValueUnknown(), diags + } + + uriVal, ok := uriAttribute.(basetypes.StringValue) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`uri expected to be basetypes.StringValue, was: %T`, uriAttribute)) + } + + if diags.HasError() { + return NewAmqpValueUnknown(), diags + } + + return AmqpValue{ + QueueName: queueNameVal, + Uri: uriVal, + state: attr.ValueStateKnown, + }, diags +} + +func NewAmqpValueMust(attributeTypes map[string]attr.Type, attributes map[string]attr.Value) AmqpValue { + object, diags := NewAmqpValue(attributeTypes, attributes) + + if diags.HasError() { + // This could potentially be added to the diag package. + diagsStrings := make([]string, 0, len(diags)) + + for _, diagnostic := range diags { + diagsStrings = append(diagsStrings, fmt.Sprintf( + "%s | %s | %s", + diagnostic.Severity(), + diagnostic.Summary(), + diagnostic.Detail())) + } + + panic("NewAmqpValueMust received error(s): " + strings.Join(diagsStrings, "\n")) + } + + return object +} + +func (t AmqpType) ValueFromTerraform(ctx context.Context, in tftypes.Value) (attr.Value, error) { + if in.Type() == nil { + return NewAmqpValueNull(), nil + } + + if !in.Type().Equal(t.TerraformType(ctx)) { + return nil, fmt.Errorf("expected %s, got %s", t.TerraformType(ctx), in.Type()) + } + + if !in.IsKnown() { + return NewAmqpValueUnknown(), nil + } + + if in.IsNull() { + return NewAmqpValueNull(), nil + } + + attributes := map[string]attr.Value{} + + val := map[string]tftypes.Value{} + + err := in.As(&val) + + if err != nil { + return nil, err + } + + for k, v := range val { + a, err := t.AttrTypes[k].ValueFromTerraform(ctx, v) + + if err != nil { + return nil, err + } + + attributes[k] = a + } + + return NewAmqpValueMust(AmqpValue{}.AttributeTypes(ctx), attributes), nil +} + +func (t AmqpType) ValueType(ctx context.Context) attr.Value { + return AmqpValue{} +} + +var _ basetypes.ObjectValuable = AmqpValue{} + +type AmqpValue struct { + QueueName basetypes.StringValue `tfsdk:"queue_name"` + Uri basetypes.StringValue `tfsdk:"uri"` + state attr.ValueState +} + +func (v AmqpValue) ToTerraformValue(ctx context.Context) (tftypes.Value, error) { + attrTypes := make(map[string]tftypes.Type, 2) + + var val tftypes.Value + var err error + + attrTypes["queue_name"] = basetypes.StringType{}.TerraformType(ctx) + attrTypes["uri"] = basetypes.StringType{}.TerraformType(ctx) + + objectType := tftypes.Object{AttributeTypes: attrTypes} + + switch v.state { + case attr.ValueStateKnown: + vals := make(map[string]tftypes.Value, 2) + + val, err = v.QueueName.ToTerraformValue(ctx) + + if err != nil { + return tftypes.NewValue(objectType, tftypes.UnknownValue), err + } + + vals["queue_name"] = val + + val, err = v.Uri.ToTerraformValue(ctx) + + if err != nil { + return tftypes.NewValue(objectType, tftypes.UnknownValue), err + } + + vals["uri"] = val + + if err := tftypes.ValidateValue(objectType, vals); err != nil { + return tftypes.NewValue(objectType, tftypes.UnknownValue), err + } + + return tftypes.NewValue(objectType, vals), nil + case attr.ValueStateNull: + return tftypes.NewValue(objectType, nil), nil + case attr.ValueStateUnknown: + return tftypes.NewValue(objectType, tftypes.UnknownValue), nil + default: + panic(fmt.Sprintf("unhandled Object state in ToTerraformValue: %s", v.state)) + } +} + +func (v AmqpValue) IsNull() bool { + return v.state == attr.ValueStateNull +} + +func (v AmqpValue) IsUnknown() bool { + return v.state == attr.ValueStateUnknown +} + +func (v AmqpValue) String() string { + return "AmqpValue" +} + +func (v AmqpValue) ToObjectValue(ctx context.Context) (basetypes.ObjectValue, diag.Diagnostics) { + var diags diag.Diagnostics + + attributeTypes := map[string]attr.Type{ + "queue_name": basetypes.StringType{}, + "uri": basetypes.StringType{}, + } + + if v.IsNull() { + return types.ObjectNull(attributeTypes), diags + } + + if v.IsUnknown() { + return types.ObjectUnknown(attributeTypes), diags + } + + objVal, diags := types.ObjectValue( + attributeTypes, + map[string]attr.Value{ + "queue_name": v.QueueName, + "uri": v.Uri, + }) + + return objVal, diags +} + +func (v AmqpValue) Equal(o attr.Value) bool { + other, ok := o.(AmqpValue) + + if !ok { + return false + } + + if v.state != other.state { + return false + } + + if v.state != attr.ValueStateKnown { + return true + } + + if !v.QueueName.Equal(other.QueueName) { + return false + } + + if !v.Uri.Equal(other.Uri) { + return false + } + + return true +} + +func (v AmqpValue) Type(ctx context.Context) attr.Type { + return AmqpType{ + basetypes.ObjectType{ + AttrTypes: v.AttributeTypes(ctx), + }, + } +} + +func (v AmqpValue) AttributeTypes(ctx context.Context) map[string]attr.Type { + return map[string]attr.Type{ + "queue_name": basetypes.StringType{}, + "uri": basetypes.StringType{}, + } +} + +var _ basetypes.ObjectTypable = MessagesType{} + +type MessagesType struct { + basetypes.ObjectType +} + +func (t MessagesType) Equal(o attr.Type) bool { + other, ok := o.(MessagesType) + + if !ok { + return false + } + + return t.ObjectType.Equal(other.ObjectType) +} + +func (t MessagesType) String() string { + return "MessagesType" +} + +func (t MessagesType) ValueFromObject(ctx context.Context, in basetypes.ObjectValue) (basetypes.ObjectValuable, diag.Diagnostics) { + var diags diag.Diagnostics + + attributes := in.Attributes() + + readyAttribute, ok := attributes["ready"] + + if !ok { + diags.AddError( + "Attribute Missing", + `ready is missing from object`) + + return nil, diags + } + + readyVal, ok := readyAttribute.(basetypes.Int64Value) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`ready expected to be basetypes.Int64Value, was: %T`, readyAttribute)) + } + + totalAttribute, ok := attributes["total"] + + if !ok { + diags.AddError( + "Attribute Missing", + `total is missing from object`) + + return nil, diags + } + + totalVal, ok := totalAttribute.(basetypes.Int64Value) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`total expected to be basetypes.Int64Value, was: %T`, totalAttribute)) + } + + unacknowledgedAttribute, ok := attributes["unacknowledged"] + + if !ok { + diags.AddError( + "Attribute Missing", + `unacknowledged is missing from object`) + + return nil, diags + } + + unacknowledgedVal, ok := unacknowledgedAttribute.(basetypes.Int64Value) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`unacknowledged expected to be basetypes.Int64Value, was: %T`, unacknowledgedAttribute)) + } + + if diags.HasError() { + return nil, diags + } + + return MessagesValue{ + Ready: readyVal, + Total: totalVal, + Unacknowledged: unacknowledgedVal, + state: attr.ValueStateKnown, + }, diags +} + +func NewMessagesValueNull() MessagesValue { + return MessagesValue{ + state: attr.ValueStateNull, + } +} + +func NewMessagesValueUnknown() MessagesValue { + return MessagesValue{ + state: attr.ValueStateUnknown, + } +} + +func NewMessagesValue(attributeTypes map[string]attr.Type, attributes map[string]attr.Value) (MessagesValue, diag.Diagnostics) { + var diags diag.Diagnostics + + // Reference: https://github.com/hashicorp/terraform-plugin-framework/issues/521 + ctx := context.Background() + + for name, attributeType := range attributeTypes { + attribute, ok := attributes[name] + + if !ok { + diags.AddError( + "Missing MessagesValue Attribute Value", + "While creating a MessagesValue value, a missing attribute value was detected. "+ + "A MessagesValue must contain values for all attributes, even if null or unknown. "+ + "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ + fmt.Sprintf("MessagesValue Attribute Name (%s) Expected Type: %s", name, attributeType.String()), + ) + + continue + } + + if !attributeType.Equal(attribute.Type(ctx)) { + diags.AddError( + "Invalid MessagesValue Attribute Type", + "While creating a MessagesValue value, an invalid attribute value was detected. "+ + "A MessagesValue must use a matching attribute type for the value. "+ + "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ + fmt.Sprintf("MessagesValue Attribute Name (%s) Expected Type: %s\n", name, attributeType.String())+ + fmt.Sprintf("MessagesValue Attribute Name (%s) Given Type: %s", name, attribute.Type(ctx)), + ) + } + } + + for name := range attributes { + _, ok := attributeTypes[name] + + if !ok { + diags.AddError( + "Extra MessagesValue Attribute Value", + "While creating a MessagesValue value, an extra attribute value was detected. "+ + "A MessagesValue must not contain values beyond the expected attribute types. "+ + "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ + fmt.Sprintf("Extra MessagesValue Attribute Name: %s", name), + ) + } + } + + if diags.HasError() { + return NewMessagesValueUnknown(), diags + } + + readyAttribute, ok := attributes["ready"] + + if !ok { + diags.AddError( + "Attribute Missing", + `ready is missing from object`) + + return NewMessagesValueUnknown(), diags + } + + readyVal, ok := readyAttribute.(basetypes.Int64Value) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`ready expected to be basetypes.Int64Value, was: %T`, readyAttribute)) + } + + totalAttribute, ok := attributes["total"] + + if !ok { + diags.AddError( + "Attribute Missing", + `total is missing from object`) + + return NewMessagesValueUnknown(), diags + } + + totalVal, ok := totalAttribute.(basetypes.Int64Value) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`total expected to be basetypes.Int64Value, was: %T`, totalAttribute)) + } + + unacknowledgedAttribute, ok := attributes["unacknowledged"] + + if !ok { + diags.AddError( + "Attribute Missing", + `unacknowledged is missing from object`) + + return NewMessagesValueUnknown(), diags + } + + unacknowledgedVal, ok := unacknowledgedAttribute.(basetypes.Int64Value) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`unacknowledged expected to be basetypes.Int64Value, was: %T`, unacknowledgedAttribute)) + } + + if diags.HasError() { + return NewMessagesValueUnknown(), diags + } + + return MessagesValue{ + Ready: readyVal, + Total: totalVal, + Unacknowledged: unacknowledgedVal, + state: attr.ValueStateKnown, + }, diags +} + +func NewMessagesValueMust(attributeTypes map[string]attr.Type, attributes map[string]attr.Value) MessagesValue { + object, diags := NewMessagesValue(attributeTypes, attributes) + + if diags.HasError() { + // This could potentially be added to the diag package. + diagsStrings := make([]string, 0, len(diags)) + + for _, diagnostic := range diags { + diagsStrings = append(diagsStrings, fmt.Sprintf( + "%s | %s | %s", + diagnostic.Severity(), + diagnostic.Summary(), + diagnostic.Detail())) + } + + panic("NewMessagesValueMust received error(s): " + strings.Join(diagsStrings, "\n")) + } + + return object +} + +func (t MessagesType) ValueFromTerraform(ctx context.Context, in tftypes.Value) (attr.Value, error) { + if in.Type() == nil { + return NewMessagesValueNull(), nil + } + + if !in.Type().Equal(t.TerraformType(ctx)) { + return nil, fmt.Errorf("expected %s, got %s", t.TerraformType(ctx), in.Type()) + } + + if !in.IsKnown() { + return NewMessagesValueUnknown(), nil + } + + if in.IsNull() { + return NewMessagesValueNull(), nil + } + + attributes := map[string]attr.Value{} + + val := map[string]tftypes.Value{} + + err := in.As(&val) + + if err != nil { + return nil, err + } + + for k, v := range val { + a, err := t.AttrTypes[k].ValueFromTerraform(ctx, v) + + if err != nil { + return nil, err + } + + attributes[k] = a + } + + return NewMessagesValueMust(MessagesValue{}.AttributeTypes(ctx), attributes), nil +} + +func (t MessagesType) ValueType(ctx context.Context) attr.Value { + return MessagesValue{} +} + +var _ basetypes.ObjectValuable = MessagesValue{} + +type MessagesValue struct { + Ready basetypes.Int64Value `tfsdk:"ready"` + Total basetypes.Int64Value `tfsdk:"total"` + Unacknowledged basetypes.Int64Value `tfsdk:"unacknowledged"` + state attr.ValueState +} + +func (v MessagesValue) ToTerraformValue(ctx context.Context) (tftypes.Value, error) { + attrTypes := make(map[string]tftypes.Type, 3) + + var val tftypes.Value + var err error + + attrTypes["ready"] = basetypes.Int64Type{}.TerraformType(ctx) + attrTypes["total"] = basetypes.Int64Type{}.TerraformType(ctx) + attrTypes["unacknowledged"] = basetypes.Int64Type{}.TerraformType(ctx) + + objectType := tftypes.Object{AttributeTypes: attrTypes} + + switch v.state { + case attr.ValueStateKnown: + vals := make(map[string]tftypes.Value, 3) + + val, err = v.Ready.ToTerraformValue(ctx) + + if err != nil { + return tftypes.NewValue(objectType, tftypes.UnknownValue), err + } + + vals["ready"] = val + + val, err = v.Total.ToTerraformValue(ctx) + + if err != nil { + return tftypes.NewValue(objectType, tftypes.UnknownValue), err + } + + vals["total"] = val + + val, err = v.Unacknowledged.ToTerraformValue(ctx) + + if err != nil { + return tftypes.NewValue(objectType, tftypes.UnknownValue), err + } + + vals["unacknowledged"] = val + + if err := tftypes.ValidateValue(objectType, vals); err != nil { + return tftypes.NewValue(objectType, tftypes.UnknownValue), err + } + + return tftypes.NewValue(objectType, vals), nil + case attr.ValueStateNull: + return tftypes.NewValue(objectType, nil), nil + case attr.ValueStateUnknown: + return tftypes.NewValue(objectType, tftypes.UnknownValue), nil + default: + panic(fmt.Sprintf("unhandled Object state in ToTerraformValue: %s", v.state)) + } +} + +func (v MessagesValue) IsNull() bool { + return v.state == attr.ValueStateNull +} + +func (v MessagesValue) IsUnknown() bool { + return v.state == attr.ValueStateUnknown +} + +func (v MessagesValue) String() string { + return "MessagesValue" +} + +func (v MessagesValue) ToObjectValue(ctx context.Context) (basetypes.ObjectValue, diag.Diagnostics) { + var diags diag.Diagnostics + + attributeTypes := map[string]attr.Type{ + "ready": basetypes.Int64Type{}, + "total": basetypes.Int64Type{}, + "unacknowledged": basetypes.Int64Type{}, + } + + if v.IsNull() { + return types.ObjectNull(attributeTypes), diags + } + + if v.IsUnknown() { + return types.ObjectUnknown(attributeTypes), diags + } + + objVal, diags := types.ObjectValue( + attributeTypes, + map[string]attr.Value{ + "ready": v.Ready, + "total": v.Total, + "unacknowledged": v.Unacknowledged, + }) + + return objVal, diags +} + +func (v MessagesValue) Equal(o attr.Value) bool { + other, ok := o.(MessagesValue) + + if !ok { + return false + } + + if v.state != other.state { + return false + } + + if v.state != attr.ValueStateKnown { + return true + } + + if !v.Ready.Equal(other.Ready) { + return false + } + + if !v.Total.Equal(other.Total) { + return false + } + + if !v.Unacknowledged.Equal(other.Unacknowledged) { + return false + } + + return true +} + +func (v MessagesValue) Type(ctx context.Context) attr.Type { + return MessagesType{ + basetypes.ObjectType{ + AttrTypes: v.AttributeTypes(ctx), + }, + } +} + +func (v MessagesValue) AttributeTypes(ctx context.Context) map[string]attr.Type { + return map[string]attr.Type{ + "ready": basetypes.Int64Type{}, + "total": basetypes.Int64Type{}, + "unacknowledged": basetypes.Int64Type{}, + } +} + +var _ basetypes.ObjectTypable = StatsType{} + +type StatsType struct { + basetypes.ObjectType +} + +func (t StatsType) Equal(o attr.Type) bool { + other, ok := o.(StatsType) + + if !ok { + return false + } + + return t.ObjectType.Equal(other.ObjectType) +} + +func (t StatsType) String() string { + return "StatsType" +} + +func (t StatsType) ValueFromObject(ctx context.Context, in basetypes.ObjectValue) (basetypes.ObjectValuable, diag.Diagnostics) { + var diags diag.Diagnostics + + attributes := in.Attributes() + + acknowledgementRateAttribute, ok := attributes["acknowledgement_rate"] + + if !ok { + diags.AddError( + "Attribute Missing", + `acknowledgement_rate is missing from object`) + + return nil, diags + } + + acknowledgementRateVal, ok := acknowledgementRateAttribute.(basetypes.NumberValue) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`acknowledgement_rate expected to be basetypes.NumberValue, was: %T`, acknowledgementRateAttribute)) + } + + deliveryRateAttribute, ok := attributes["delivery_rate"] + + if !ok { + diags.AddError( + "Attribute Missing", + `delivery_rate is missing from object`) + + return nil, diags + } + + deliveryRateVal, ok := deliveryRateAttribute.(basetypes.NumberValue) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`delivery_rate expected to be basetypes.NumberValue, was: %T`, deliveryRateAttribute)) + } + + publishRateAttribute, ok := attributes["publish_rate"] + + if !ok { + diags.AddError( + "Attribute Missing", + `publish_rate is missing from object`) + + return nil, diags + } + + publishRateVal, ok := publishRateAttribute.(basetypes.NumberValue) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`publish_rate expected to be basetypes.NumberValue, was: %T`, publishRateAttribute)) + } + + if diags.HasError() { + return nil, diags + } + + return StatsValue{ + AcknowledgementRate: acknowledgementRateVal, + DeliveryRate: deliveryRateVal, + PublishRate: publishRateVal, + state: attr.ValueStateKnown, + }, diags +} + +func NewStatsValueNull() StatsValue { + return StatsValue{ + state: attr.ValueStateNull, + } +} + +func NewStatsValueUnknown() StatsValue { + return StatsValue{ + state: attr.ValueStateUnknown, + } +} + +func NewStatsValue(attributeTypes map[string]attr.Type, attributes map[string]attr.Value) (StatsValue, diag.Diagnostics) { + var diags diag.Diagnostics + + // Reference: https://github.com/hashicorp/terraform-plugin-framework/issues/521 + ctx := context.Background() + + for name, attributeType := range attributeTypes { + attribute, ok := attributes[name] + + if !ok { + diags.AddError( + "Missing StatsValue Attribute Value", + "While creating a StatsValue value, a missing attribute value was detected. "+ + "A StatsValue must contain values for all attributes, even if null or unknown. "+ + "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ + fmt.Sprintf("StatsValue Attribute Name (%s) Expected Type: %s", name, attributeType.String()), + ) + + continue + } + + if !attributeType.Equal(attribute.Type(ctx)) { + diags.AddError( + "Invalid StatsValue Attribute Type", + "While creating a StatsValue value, an invalid attribute value was detected. "+ + "A StatsValue must use a matching attribute type for the value. "+ + "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ + fmt.Sprintf("StatsValue Attribute Name (%s) Expected Type: %s\n", name, attributeType.String())+ + fmt.Sprintf("StatsValue Attribute Name (%s) Given Type: %s", name, attribute.Type(ctx)), + ) + } + } + + for name := range attributes { + _, ok := attributeTypes[name] + + if !ok { + diags.AddError( + "Extra StatsValue Attribute Value", + "While creating a StatsValue value, an extra attribute value was detected. "+ + "A StatsValue must not contain values beyond the expected attribute types. "+ + "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ + fmt.Sprintf("Extra StatsValue Attribute Name: %s", name), + ) + } + } + + if diags.HasError() { + return NewStatsValueUnknown(), diags + } + + acknowledgementRateAttribute, ok := attributes["acknowledgement_rate"] + + if !ok { + diags.AddError( + "Attribute Missing", + `acknowledgement_rate is missing from object`) + + return NewStatsValueUnknown(), diags + } + + acknowledgementRateVal, ok := acknowledgementRateAttribute.(basetypes.NumberValue) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`acknowledgement_rate expected to be basetypes.NumberValue, was: %T`, acknowledgementRateAttribute)) + } + + deliveryRateAttribute, ok := attributes["delivery_rate"] + + if !ok { + diags.AddError( + "Attribute Missing", + `delivery_rate is missing from object`) + + return NewStatsValueUnknown(), diags + } + + deliveryRateVal, ok := deliveryRateAttribute.(basetypes.NumberValue) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`delivery_rate expected to be basetypes.NumberValue, was: %T`, deliveryRateAttribute)) + } + + publishRateAttribute, ok := attributes["publish_rate"] + + if !ok { + diags.AddError( + "Attribute Missing", + `publish_rate is missing from object`) + + return NewStatsValueUnknown(), diags + } + + publishRateVal, ok := publishRateAttribute.(basetypes.NumberValue) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`publish_rate expected to be basetypes.NumberValue, was: %T`, publishRateAttribute)) + } + + if diags.HasError() { + return NewStatsValueUnknown(), diags + } + + return StatsValue{ + AcknowledgementRate: acknowledgementRateVal, + DeliveryRate: deliveryRateVal, + PublishRate: publishRateVal, + state: attr.ValueStateKnown, + }, diags +} + +func NewStatsValueMust(attributeTypes map[string]attr.Type, attributes map[string]attr.Value) StatsValue { + object, diags := NewStatsValue(attributeTypes, attributes) + + if diags.HasError() { + // This could potentially be added to the diag package. + diagsStrings := make([]string, 0, len(diags)) + + for _, diagnostic := range diags { + diagsStrings = append(diagsStrings, fmt.Sprintf( + "%s | %s | %s", + diagnostic.Severity(), + diagnostic.Summary(), + diagnostic.Detail())) + } + + panic("NewStatsValueMust received error(s): " + strings.Join(diagsStrings, "\n")) + } + + return object +} + +func (t StatsType) ValueFromTerraform(ctx context.Context, in tftypes.Value) (attr.Value, error) { + if in.Type() == nil { + return NewStatsValueNull(), nil + } + + if !in.Type().Equal(t.TerraformType(ctx)) { + return nil, fmt.Errorf("expected %s, got %s", t.TerraformType(ctx), in.Type()) + } + + if !in.IsKnown() { + return NewStatsValueUnknown(), nil + } + + if in.IsNull() { + return NewStatsValueNull(), nil + } + + attributes := map[string]attr.Value{} + + val := map[string]tftypes.Value{} + + err := in.As(&val) + + if err != nil { + return nil, err + } + + for k, v := range val { + a, err := t.AttrTypes[k].ValueFromTerraform(ctx, v) + + if err != nil { + return nil, err + } + + attributes[k] = a + } + + return NewStatsValueMust(StatsValue{}.AttributeTypes(ctx), attributes), nil +} + +func (t StatsType) ValueType(ctx context.Context) attr.Value { + return StatsValue{} +} + +var _ basetypes.ObjectValuable = StatsValue{} + +type StatsValue struct { + AcknowledgementRate basetypes.NumberValue `tfsdk:"acknowledgement_rate"` + DeliveryRate basetypes.NumberValue `tfsdk:"delivery_rate"` + PublishRate basetypes.NumberValue `tfsdk:"publish_rate"` + state attr.ValueState +} + +func (v StatsValue) ToTerraformValue(ctx context.Context) (tftypes.Value, error) { + attrTypes := make(map[string]tftypes.Type, 3) + + var val tftypes.Value + var err error + + attrTypes["acknowledgement_rate"] = basetypes.NumberType{}.TerraformType(ctx) + attrTypes["delivery_rate"] = basetypes.NumberType{}.TerraformType(ctx) + attrTypes["publish_rate"] = basetypes.NumberType{}.TerraformType(ctx) + + objectType := tftypes.Object{AttributeTypes: attrTypes} + + switch v.state { + case attr.ValueStateKnown: + vals := make(map[string]tftypes.Value, 3) + + val, err = v.AcknowledgementRate.ToTerraformValue(ctx) + + if err != nil { + return tftypes.NewValue(objectType, tftypes.UnknownValue), err + } + + vals["acknowledgement_rate"] = val + + val, err = v.DeliveryRate.ToTerraformValue(ctx) + + if err != nil { + return tftypes.NewValue(objectType, tftypes.UnknownValue), err + } + + vals["delivery_rate"] = val + + val, err = v.PublishRate.ToTerraformValue(ctx) + + if err != nil { + return tftypes.NewValue(objectType, tftypes.UnknownValue), err + } + + vals["publish_rate"] = val + + if err := tftypes.ValidateValue(objectType, vals); err != nil { + return tftypes.NewValue(objectType, tftypes.UnknownValue), err + } + + return tftypes.NewValue(objectType, vals), nil + case attr.ValueStateNull: + return tftypes.NewValue(objectType, nil), nil + case attr.ValueStateUnknown: + return tftypes.NewValue(objectType, tftypes.UnknownValue), nil + default: + panic(fmt.Sprintf("unhandled Object state in ToTerraformValue: %s", v.state)) + } +} + +func (v StatsValue) IsNull() bool { + return v.state == attr.ValueStateNull +} + +func (v StatsValue) IsUnknown() bool { + return v.state == attr.ValueStateUnknown +} + +func (v StatsValue) String() string { + return "StatsValue" +} + +func (v StatsValue) ToObjectValue(ctx context.Context) (basetypes.ObjectValue, diag.Diagnostics) { + var diags diag.Diagnostics + + attributeTypes := map[string]attr.Type{ + "acknowledgement_rate": basetypes.NumberType{}, + "delivery_rate": basetypes.NumberType{}, + "publish_rate": basetypes.NumberType{}, + } + + if v.IsNull() { + return types.ObjectNull(attributeTypes), diags + } + + if v.IsUnknown() { + return types.ObjectUnknown(attributeTypes), diags + } + + objVal, diags := types.ObjectValue( + attributeTypes, + map[string]attr.Value{ + "acknowledgement_rate": v.AcknowledgementRate, + "delivery_rate": v.DeliveryRate, + "publish_rate": v.PublishRate, + }) + + return objVal, diags +} + +func (v StatsValue) Equal(o attr.Value) bool { + other, ok := o.(StatsValue) + + if !ok { + return false + } + + if v.state != other.state { + return false + } + + if v.state != attr.ValueStateKnown { + return true + } + + if !v.AcknowledgementRate.Equal(other.AcknowledgementRate) { + return false + } + + if !v.DeliveryRate.Equal(other.DeliveryRate) { + return false + } + + if !v.PublishRate.Equal(other.PublishRate) { + return false + } + + return true +} + +func (v StatsValue) Type(ctx context.Context) attr.Type { + return StatsType{ + basetypes.ObjectType{ + AttrTypes: v.AttributeTypes(ctx), + }, + } +} + +func (v StatsValue) AttributeTypes(ctx context.Context) map[string]attr.Type { + return map[string]attr.Type{ + "acknowledgement_rate": basetypes.NumberType{}, + "delivery_rate": basetypes.NumberType{}, + "publish_rate": basetypes.NumberType{}, + } +} + +var _ basetypes.ObjectTypable = StompType{} + +type StompType struct { + basetypes.ObjectType +} + +func (t StompType) Equal(o attr.Type) bool { + other, ok := o.(StompType) + + if !ok { + return false + } + + return t.ObjectType.Equal(other.ObjectType) +} + +func (t StompType) String() string { + return "StompType" +} + +func (t StompType) ValueFromObject(ctx context.Context, in basetypes.ObjectValue) (basetypes.ObjectValuable, diag.Diagnostics) { + var diags diag.Diagnostics + + attributes := in.Attributes() + + destinationAttribute, ok := attributes["destination"] + + if !ok { + diags.AddError( + "Attribute Missing", + `destination is missing from object`) + + return nil, diags + } + + destinationVal, ok := destinationAttribute.(basetypes.StringValue) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`destination expected to be basetypes.StringValue, was: %T`, destinationAttribute)) + } + + hostAttribute, ok := attributes["host"] + + if !ok { + diags.AddError( + "Attribute Missing", + `host is missing from object`) + + return nil, diags + } + + hostVal, ok := hostAttribute.(basetypes.StringValue) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`host expected to be basetypes.StringValue, was: %T`, hostAttribute)) + } + + uriAttribute, ok := attributes["uri"] + + if !ok { + diags.AddError( + "Attribute Missing", + `uri is missing from object`) + + return nil, diags + } + + uriVal, ok := uriAttribute.(basetypes.StringValue) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`uri expected to be basetypes.StringValue, was: %T`, uriAttribute)) + } + + if diags.HasError() { + return nil, diags + } + + return StompValue{ + Destination: destinationVal, + Host: hostVal, + Uri: uriVal, + state: attr.ValueStateKnown, + }, diags +} + +func NewStompValueNull() StompValue { + return StompValue{ + state: attr.ValueStateNull, + } +} + +func NewStompValueUnknown() StompValue { + return StompValue{ + state: attr.ValueStateUnknown, + } +} + +func NewStompValue(attributeTypes map[string]attr.Type, attributes map[string]attr.Value) (StompValue, diag.Diagnostics) { + var diags diag.Diagnostics + + // Reference: https://github.com/hashicorp/terraform-plugin-framework/issues/521 + ctx := context.Background() + + for name, attributeType := range attributeTypes { + attribute, ok := attributes[name] + + if !ok { + diags.AddError( + "Missing StompValue Attribute Value", + "While creating a StompValue value, a missing attribute value was detected. "+ + "A StompValue must contain values for all attributes, even if null or unknown. "+ + "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ + fmt.Sprintf("StompValue Attribute Name (%s) Expected Type: %s", name, attributeType.String()), + ) + + continue + } + + if !attributeType.Equal(attribute.Type(ctx)) { + diags.AddError( + "Invalid StompValue Attribute Type", + "While creating a StompValue value, an invalid attribute value was detected. "+ + "A StompValue must use a matching attribute type for the value. "+ + "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ + fmt.Sprintf("StompValue Attribute Name (%s) Expected Type: %s\n", name, attributeType.String())+ + fmt.Sprintf("StompValue Attribute Name (%s) Given Type: %s", name, attribute.Type(ctx)), + ) + } + } + + for name := range attributes { + _, ok := attributeTypes[name] + + if !ok { + diags.AddError( + "Extra StompValue Attribute Value", + "While creating a StompValue value, an extra attribute value was detected. "+ + "A StompValue must not contain values beyond the expected attribute types. "+ + "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ + fmt.Sprintf("Extra StompValue Attribute Name: %s", name), + ) + } + } + + if diags.HasError() { + return NewStompValueUnknown(), diags + } + + destinationAttribute, ok := attributes["destination"] + + if !ok { + diags.AddError( + "Attribute Missing", + `destination is missing from object`) + + return NewStompValueUnknown(), diags + } + + destinationVal, ok := destinationAttribute.(basetypes.StringValue) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`destination expected to be basetypes.StringValue, was: %T`, destinationAttribute)) + } + + hostAttribute, ok := attributes["host"] + + if !ok { + diags.AddError( + "Attribute Missing", + `host is missing from object`) + + return NewStompValueUnknown(), diags + } + + hostVal, ok := hostAttribute.(basetypes.StringValue) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`host expected to be basetypes.StringValue, was: %T`, hostAttribute)) + } + + uriAttribute, ok := attributes["uri"] + + if !ok { + diags.AddError( + "Attribute Missing", + `uri is missing from object`) + + return NewStompValueUnknown(), diags + } + + uriVal, ok := uriAttribute.(basetypes.StringValue) + + if !ok { + diags.AddError( + "Attribute Wrong Type", + fmt.Sprintf(`uri expected to be basetypes.StringValue, was: %T`, uriAttribute)) + } + + if diags.HasError() { + return NewStompValueUnknown(), diags + } + + return StompValue{ + Destination: destinationVal, + Host: hostVal, + Uri: uriVal, + state: attr.ValueStateKnown, + }, diags +} + +func NewStompValueMust(attributeTypes map[string]attr.Type, attributes map[string]attr.Value) StompValue { + object, diags := NewStompValue(attributeTypes, attributes) + + if diags.HasError() { + // This could potentially be added to the diag package. + diagsStrings := make([]string, 0, len(diags)) + + for _, diagnostic := range diags { + diagsStrings = append(diagsStrings, fmt.Sprintf( + "%s | %s | %s", + diagnostic.Severity(), + diagnostic.Summary(), + diagnostic.Detail())) + } + + panic("NewStompValueMust received error(s): " + strings.Join(diagsStrings, "\n")) + } + + return object +} + +func (t StompType) ValueFromTerraform(ctx context.Context, in tftypes.Value) (attr.Value, error) { + if in.Type() == nil { + return NewStompValueNull(), nil + } + + if !in.Type().Equal(t.TerraformType(ctx)) { + return nil, fmt.Errorf("expected %s, got %s", t.TerraformType(ctx), in.Type()) + } + + if !in.IsKnown() { + return NewStompValueUnknown(), nil + } + + if in.IsNull() { + return NewStompValueNull(), nil + } + + attributes := map[string]attr.Value{} + + val := map[string]tftypes.Value{} + + err := in.As(&val) + + if err != nil { + return nil, err + } + + for k, v := range val { + a, err := t.AttrTypes[k].ValueFromTerraform(ctx, v) + + if err != nil { + return nil, err + } + + attributes[k] = a + } + + return NewStompValueMust(StompValue{}.AttributeTypes(ctx), attributes), nil +} + +func (t StompType) ValueType(ctx context.Context) attr.Value { + return StompValue{} +} + +var _ basetypes.ObjectValuable = StompValue{} + +type StompValue struct { + Destination basetypes.StringValue `tfsdk:"destination"` + Host basetypes.StringValue `tfsdk:"host"` + Uri basetypes.StringValue `tfsdk:"uri"` + state attr.ValueState +} + +func (v StompValue) ToTerraformValue(ctx context.Context) (tftypes.Value, error) { + attrTypes := make(map[string]tftypes.Type, 3) + + var val tftypes.Value + var err error + + attrTypes["destination"] = basetypes.StringType{}.TerraformType(ctx) + attrTypes["host"] = basetypes.StringType{}.TerraformType(ctx) + attrTypes["uri"] = basetypes.StringType{}.TerraformType(ctx) + + objectType := tftypes.Object{AttributeTypes: attrTypes} + + switch v.state { + case attr.ValueStateKnown: + vals := make(map[string]tftypes.Value, 3) + + val, err = v.Destination.ToTerraformValue(ctx) + + if err != nil { + return tftypes.NewValue(objectType, tftypes.UnknownValue), err + } + + vals["destination"] = val + + val, err = v.Host.ToTerraformValue(ctx) + + if err != nil { + return tftypes.NewValue(objectType, tftypes.UnknownValue), err + } + + vals["host"] = val + + val, err = v.Uri.ToTerraformValue(ctx) + + if err != nil { + return tftypes.NewValue(objectType, tftypes.UnknownValue), err + } + + vals["uri"] = val + + if err := tftypes.ValidateValue(objectType, vals); err != nil { + return tftypes.NewValue(objectType, tftypes.UnknownValue), err + } + + return tftypes.NewValue(objectType, vals), nil + case attr.ValueStateNull: + return tftypes.NewValue(objectType, nil), nil + case attr.ValueStateUnknown: + return tftypes.NewValue(objectType, tftypes.UnknownValue), nil + default: + panic(fmt.Sprintf("unhandled Object state in ToTerraformValue: %s", v.state)) + } +} + +func (v StompValue) IsNull() bool { + return v.state == attr.ValueStateNull +} + +func (v StompValue) IsUnknown() bool { + return v.state == attr.ValueStateUnknown +} + +func (v StompValue) String() string { + return "StompValue" +} + +func (v StompValue) ToObjectValue(ctx context.Context) (basetypes.ObjectValue, diag.Diagnostics) { + var diags diag.Diagnostics + + attributeTypes := map[string]attr.Type{ + "destination": basetypes.StringType{}, + "host": basetypes.StringType{}, + "uri": basetypes.StringType{}, + } + + if v.IsNull() { + return types.ObjectNull(attributeTypes), diags + } + + if v.IsUnknown() { + return types.ObjectUnknown(attributeTypes), diags + } + + objVal, diags := types.ObjectValue( + attributeTypes, + map[string]attr.Value{ + "destination": v.Destination, + "host": v.Host, + "uri": v.Uri, + }) + + return objVal, diags +} + +func (v StompValue) Equal(o attr.Value) bool { + other, ok := o.(StompValue) + + if !ok { + return false + } + + if v.state != other.state { + return false + } + + if v.state != attr.ValueStateKnown { + return true + } + + if !v.Destination.Equal(other.Destination) { + return false + } + + if !v.Host.Equal(other.Host) { + return false + } + + if !v.Uri.Equal(other.Uri) { + return false + } + + return true +} + +func (v StompValue) Type(ctx context.Context) attr.Type { + return StompType{ + basetypes.ObjectType{ + AttrTypes: v.AttributeTypes(ctx), + }, + } +} + +func (v StompValue) AttributeTypes(ctx context.Context) map[string]attr.Type { + return map[string]attr.Type{ + "destination": basetypes.StringType{}, + "host": basetypes.StringType{}, + "uri": basetypes.StringType{}, + } +} diff --git a/internal/provider/codegen/resource_queue/queue_resource_gen.go b/internal/provider/codegen/resource_queue/queue_resource_gen.go index 8a1db84..0528f60 100644 --- a/internal/provider/codegen/resource_queue/queue_resource_gen.go +++ b/internal/provider/codegen/resource_queue/queue_resource_gen.go @@ -5,11 +5,14 @@ package resource_queue import ( "context" "fmt" + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-framework/types/basetypes" "github.com/hashicorp/terraform-plugin-go/tftypes" + "regexp" "strings" "github.com/hashicorp/terraform-plugin-framework/resource/schema" @@ -92,8 +95,12 @@ func QueueResourceSchema(ctx context.Context) schema.Schema { }, "name": schema.StringAttribute{ Required: true, - Description: "A friendly name for your queue.", - MarkdownDescription: "A friendly name for your queue.", + Description: "A friendly name for your queue. May only contain letters, numbers, hyphens and underscores, up to 64 characters.", + MarkdownDescription: "A friendly name for your queue. May only contain letters, numbers, hyphens and underscores, up to 64 characters.", + Validators: []validator.String{ + stringvalidator.LengthAtMost(64), + stringvalidator.RegexMatches(regexp.MustCompile("^[A-Za-z0-9_-]+$"), ""), + }, }, "parent_app_id": schema.StringAttribute{ Optional: true, diff --git a/internal/provider/codegen/resource_rule_azure_moderation/rule_azure_moderation_resource_gen.go b/internal/provider/codegen/resource_rule_azure_moderation/rule_azure_moderation_resource_gen.go index ce367db..0eca31f 100644 --- a/internal/provider/codegen/resource_rule_azure_moderation/rule_azure_moderation_resource_gen.go +++ b/internal/provider/codegen/resource_rule_azure_moderation/rule_azure_moderation_resource_gen.go @@ -7,6 +7,7 @@ import ( "fmt" "github.com/ably/terraform-provider-ably/internal/provider/planmodifiers" "github.com/hashicorp/terraform-plugin-framework-validators/int64validator" + "github.com/hashicorp/terraform-plugin-framework-validators/mapvalidator" "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/diag" @@ -142,6 +143,9 @@ func RuleAzureModerationResourceSchema(ctx context.Context) schema.Schema { Optional: true, Description: "A map of moderation categories to threshold levels (0-7). Messages scoring above the threshold for any category will be rejected.", MarkdownDescription: "A map of moderation categories to threshold levels (0-7). Messages scoring above the threshold for any category will be rejected.", + Validators: []validator.Map{ + mapvalidator.SizeAtLeast(1), + }, }, }, CustomType: TargetType{ diff --git a/internal/provider/codegen/resource_rule_before_publish_lambda/rule_before_publish_lambda_resource_gen.go b/internal/provider/codegen/resource_rule_before_publish_lambda/rule_before_publish_lambda_resource_gen.go index 5647e1d..76c14a0 100644 --- a/internal/provider/codegen/resource_rule_before_publish_lambda/rule_before_publish_lambda_resource_gen.go +++ b/internal/provider/codegen/resource_rule_before_publish_lambda/rule_before_publish_lambda_resource_gen.go @@ -10,6 +10,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/objectplanmodifier" "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringdefault" "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" @@ -110,17 +111,12 @@ func RuleBeforePublishLambdaResourceSchema(ctx context.Context) schema.Schema { }, "source": schema.SingleNestedAttribute{ Attributes: map[string]schema.Attribute{ - "channel_filter": schema.StringAttribute{ - Required: true, - Description: "This field allows you to filter your rule based on a regular expression that is matched against the complete channel name. Leave this empty if you want the rule to apply to all channels.", - MarkdownDescription: "This field allows you to filter your rule based on a regular expression that is matched against the complete channel name. Leave this empty if you want the rule to apply to all channels.", - }, "type": schema.StringAttribute{ Required: true, - Description: "Ably currently supports the following sources for all rule types, in both single and batch mode: `channel.message`, `channel.presence`, `channel.lifecycle` and `channel.occupancy`. If the source `channel.message` is selected, you receive notifications when messages are published on a channel. If the source `channel.presence` is selected, you receive notifications of presence events when clients enter, update their data, or leave channels. If the source `channel.lifecycle` is selected, you receive notifications of channel lifecycle events, such as when a channel is created (following the first client attaching to this channel) or discarded (when there are no more clients attached to the channel). If the source `channel.occupancy` is selected, you receive notifications of occupancy events, which relate to the number and type of occupants in the channel.", - MarkdownDescription: "Ably currently supports the following sources for all rule types, in both single and batch mode: `channel.message`, `channel.presence`, `channel.lifecycle` and `channel.occupancy`. If the source `channel.message` is selected, you receive notifications when messages are published on a channel. If the source `channel.presence` is selected, you receive notifications of presence events when clients enter, update their data, or leave channels. If the source `channel.lifecycle` is selected, you receive notifications of channel lifecycle events, such as when a channel is created (following the first client attaching to this channel) or discarded (when there are no more clients attached to the channel). If the source `channel.occupancy` is selected, you receive notifications of occupancy events, which relate to the number and type of occupants in the channel.", + Description: "The source type. Before-publish rules act on chat messages, so `chat.message` is the only supported value.", + MarkdownDescription: "The source type. Before-publish rules act on chat messages, so `chat.message` is the only supported value.", Validators: []validator.String{ - stringvalidator.LengthAtLeast(1), + stringvalidator.OneOf("chat.message"), }, }, }, @@ -129,7 +125,13 @@ func RuleBeforePublishLambdaResourceSchema(ctx context.Context) schema.Schema { AttrTypes: SourceValue{}.AttributeTypes(ctx), }, }, - Optional: true, + Optional: true, + Computed: true, + Description: "The source of messages this rule applies to. Optional: the Control API assigns a default source when it is omitted.", + MarkdownDescription: "The source of messages this rule applies to. Optional: the Control API assigns a default source when it is omitted.", + PlanModifiers: []planmodifier.Object{ + objectplanmodifier.UseStateForUnknown(), + }, }, "status": schema.StringAttribute{ Optional: true, @@ -742,24 +744,6 @@ func (t SourceType) ValueFromObject(ctx context.Context, in basetypes.ObjectValu attributes := in.Attributes() - channelFilterAttribute, ok := attributes["channel_filter"] - - if !ok { - diags.AddError( - "Attribute Missing", - `channel_filter is missing from object`) - - return nil, diags - } - - channelFilterVal, ok := channelFilterAttribute.(basetypes.StringValue) - - if !ok { - diags.AddError( - "Attribute Wrong Type", - fmt.Sprintf(`channel_filter expected to be basetypes.StringValue, was: %T`, channelFilterAttribute)) - } - typeAttribute, ok := attributes["type"] if !ok { @@ -783,9 +767,8 @@ func (t SourceType) ValueFromObject(ctx context.Context, in basetypes.ObjectValu } return SourceValue{ - ChannelFilter: channelFilterVal, - SourceType: typeVal, - state: attr.ValueStateKnown, + SourceType: typeVal, + state: attr.ValueStateKnown, }, diags } @@ -852,24 +835,6 @@ func NewSourceValue(attributeTypes map[string]attr.Type, attributes map[string]a return NewSourceValueUnknown(), diags } - channelFilterAttribute, ok := attributes["channel_filter"] - - if !ok { - diags.AddError( - "Attribute Missing", - `channel_filter is missing from object`) - - return NewSourceValueUnknown(), diags - } - - channelFilterVal, ok := channelFilterAttribute.(basetypes.StringValue) - - if !ok { - diags.AddError( - "Attribute Wrong Type", - fmt.Sprintf(`channel_filter expected to be basetypes.StringValue, was: %T`, channelFilterAttribute)) - } - typeAttribute, ok := attributes["type"] if !ok { @@ -893,9 +858,8 @@ func NewSourceValue(attributeTypes map[string]attr.Type, attributes map[string]a } return SourceValue{ - ChannelFilter: channelFilterVal, - SourceType: typeVal, - state: attr.ValueStateKnown, + SourceType: typeVal, + state: attr.ValueStateKnown, }, diags } @@ -967,33 +931,23 @@ func (t SourceType) ValueType(ctx context.Context) attr.Value { var _ basetypes.ObjectValuable = SourceValue{} type SourceValue struct { - ChannelFilter basetypes.StringValue `tfsdk:"channel_filter"` - SourceType basetypes.StringValue `tfsdk:"type"` - state attr.ValueState + SourceType basetypes.StringValue `tfsdk:"type"` + state attr.ValueState } func (v SourceValue) ToTerraformValue(ctx context.Context) (tftypes.Value, error) { - attrTypes := make(map[string]tftypes.Type, 2) + attrTypes := make(map[string]tftypes.Type, 1) var val tftypes.Value var err error - attrTypes["channel_filter"] = basetypes.StringType{}.TerraformType(ctx) attrTypes["type"] = basetypes.StringType{}.TerraformType(ctx) objectType := tftypes.Object{AttributeTypes: attrTypes} switch v.state { case attr.ValueStateKnown: - vals := make(map[string]tftypes.Value, 2) - - val, err = v.ChannelFilter.ToTerraformValue(ctx) - - if err != nil { - return tftypes.NewValue(objectType, tftypes.UnknownValue), err - } - - vals["channel_filter"] = val + vals := make(map[string]tftypes.Value, 1) val, err = v.SourceType.ToTerraformValue(ctx) @@ -1033,8 +987,7 @@ func (v SourceValue) ToObjectValue(ctx context.Context) (basetypes.ObjectValue, var diags diag.Diagnostics attributeTypes := map[string]attr.Type{ - "channel_filter": basetypes.StringType{}, - "type": basetypes.StringType{}, + "type": basetypes.StringType{}, } if v.IsNull() { @@ -1048,8 +1001,7 @@ func (v SourceValue) ToObjectValue(ctx context.Context) (basetypes.ObjectValue, objVal, diags := types.ObjectValue( attributeTypes, map[string]attr.Value{ - "channel_filter": v.ChannelFilter, - "type": v.SourceType, + "type": v.SourceType, }) return objVal, diags @@ -1070,10 +1022,6 @@ func (v SourceValue) Equal(o attr.Value) bool { return true } - if !v.ChannelFilter.Equal(other.ChannelFilter) { - return false - } - if !v.SourceType.Equal(other.SourceType) { return false } @@ -1091,8 +1039,7 @@ func (v SourceValue) Type(ctx context.Context) attr.Type { func (v SourceValue) AttributeTypes(ctx context.Context) map[string]attr.Type { return map[string]attr.Type{ - "channel_filter": basetypes.StringType{}, - "type": basetypes.StringType{}, + "type": basetypes.StringType{}, } } diff --git a/internal/provider/codegen/resource_rule_before_publish_webhook/rule_before_publish_webhook_resource_gen.go b/internal/provider/codegen/resource_rule_before_publish_webhook/rule_before_publish_webhook_resource_gen.go index fa6c00d..dc759a9 100644 --- a/internal/provider/codegen/resource_rule_before_publish_webhook/rule_before_publish_webhook_resource_gen.go +++ b/internal/provider/codegen/resource_rule_before_publish_webhook/rule_before_publish_webhook_resource_gen.go @@ -133,6 +133,7 @@ func RuleBeforePublishWebhookResourceSchema(ctx context.Context) schema.Schema { }, "value": schema.StringAttribute{ Optional: true, + Sensitive: true, Description: "The value of the header.", MarkdownDescription: "The value of the header.", Validators: []validator.String{ diff --git a/internal/provider/codegen/resource_rule_hive_text/rule_hive_text_resource_gen.go b/internal/provider/codegen/resource_rule_hive_text/rule_hive_text_resource_gen.go index 596fbf4..8461583 100644 --- a/internal/provider/codegen/resource_rule_hive_text/rule_hive_text_resource_gen.go +++ b/internal/provider/codegen/resource_rule_hive_text/rule_hive_text_resource_gen.go @@ -7,6 +7,7 @@ import ( "fmt" "github.com/ably/terraform-provider-ably/internal/provider/planmodifiers" "github.com/hashicorp/terraform-plugin-framework-validators/int64validator" + "github.com/hashicorp/terraform-plugin-framework-validators/mapvalidator" "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/diag" @@ -142,6 +143,9 @@ func RuleHiveTextResourceSchema(ctx context.Context) schema.Schema { Optional: true, Description: "A map of moderation categories to threshold levels (1-3). Messages scoring above the threshold for any category will be rejected.", MarkdownDescription: "A map of moderation categories to threshold levels (1-3). Messages scoring above the threshold for any category will be rejected.", + Validators: []validator.Map{ + mapvalidator.SizeAtLeast(1), + }, }, }, CustomType: TargetType{ diff --git a/internal/provider/codegen/resource_rule_tisane/rule_tisane_resource_gen.go b/internal/provider/codegen/resource_rule_tisane/rule_tisane_resource_gen.go index 6fc128c..c88b597 100644 --- a/internal/provider/codegen/resource_rule_tisane/rule_tisane_resource_gen.go +++ b/internal/provider/codegen/resource_rule_tisane/rule_tisane_resource_gen.go @@ -7,6 +7,7 @@ import ( "fmt" "github.com/ably/terraform-provider-ably/internal/provider/planmodifiers" "github.com/hashicorp/terraform-plugin-framework-validators/int64validator" + "github.com/hashicorp/terraform-plugin-framework-validators/mapvalidator" "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/diag" @@ -150,6 +151,9 @@ func RuleTisaneResourceSchema(ctx context.Context) schema.Schema { Optional: true, Description: "A map of moderation categories to threshold levels (0-3). Messages scoring above the threshold for any category will be rejected.", MarkdownDescription: "A map of moderation categories to threshold levels (0-3). Messages scoring above the threshold for any category will be rejected.", + Validators: []validator.Map{ + mapvalidator.SizeAtLeast(1), + }, }, }, CustomType: TargetType{ diff --git a/internal/provider/data_source_ably_app.go b/internal/provider/data_source_ably_app.go new file mode 100644 index 0000000..65e1907 --- /dev/null +++ b/internal/provider/data_source_ably_app.go @@ -0,0 +1,206 @@ +// Package provider implements the Ably provider for Terraform +package provider + +import ( + "context" + + "github.com/ably/terraform-provider-ably/control" + "github.com/ably/terraform-provider-ably/internal/provider/codegen/datasource_apps" + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +// AblyAppDataSourceModel is one app as the data sources report it. +// +// It mirrors the generated element attributes of the ably_apps collection, and +// serves as the model for both data sources: the whole point of lifting the +// element attributes for the singular schema (see data_sources.go) is that one +// struct fits both. +// +// Note the timestamps are Unix milliseconds here, matching the API and the +// generated schema, where the ably_app resource carries RFC3339 strings for +// backwards compatibility with existing state. +type AblyAppDataSourceModel struct { + ID types.String `tfsdk:"id"` + AccountID types.String `tfsdk:"account_id"` + Name types.String `tfsdk:"name"` + Status types.String `tfsdk:"status"` + TLSOnly types.Bool `tfsdk:"tls_only"` + APNSUseSandboxEndpoint types.Bool `tfsdk:"apns_use_sandbox_endpoint"` + APNSAuthType types.String `tfsdk:"apns_auth_type"` + APNSIssuerKey types.String `tfsdk:"apns_issuer_key"` + APNSSigningKeyID types.String `tfsdk:"apns_signing_key_id"` + APNSTopicHeader types.String `tfsdk:"apns_topic_header"` + APNSCertificateConfigured types.Bool `tfsdk:"apns_certificate_configured"` + APNSSigningKeyConfigured types.Bool `tfsdk:"apns_signing_key_configured"` + FCMProjectID types.String `tfsdk:"fcm_project_id"` + FCMServiceAccountConfigured types.Bool `tfsdk:"fcm_service_account_configured"` + Created types.Int64 `tfsdk:"created"` + Modified types.Int64 `tfsdk:"modified"` +} + +// AblyAppsDataSourceModel is the model for the plural ably_apps data source. +type AblyAppsDataSourceModel struct { + AccountID types.String `tfsdk:"account_id"` + Apps []AblyAppDataSourceModel `tfsdk:"apps"` +} + +// appDataSourceModel maps an API app onto the shared model. +func appDataSourceModel(app control.AppResponse) AblyAppDataSourceModel { + return AblyAppDataSourceModel{ + ID: types.StringValue(app.ID), + AccountID: types.StringValue(app.AccountID), + Name: types.StringValue(app.Name), + Status: types.StringValue(app.Status), + TLSOnly: optBoolValue(app.TLSOnly), + APNSUseSandboxEndpoint: optBoolValue(app.APNSUseSandboxEndpoint), + APNSAuthType: optStringValue(app.APNSAuthType), + APNSIssuerKey: optStringValue(app.APNSIssuerKey), + APNSSigningKeyID: optStringValue(app.APNSSigningKeyID), + APNSTopicHeader: optStringValue(app.APNSTopicHeader), + APNSCertificateConfigured: optBoolValue(app.APNSCertificateConfigured), + APNSSigningKeyConfigured: optBoolValue(app.APNSSigningKeyConfigured), + FCMProjectID: optStringValue(app.FCMProjectID), + FCMServiceAccountConfigured: optBoolValue(app.FCMServiceAccountConfigured), + Created: types.Int64Value(app.Created), + Modified: types.Int64Value(app.Modified), + } +} + +// appsDataSourceSchema returns the generated ably_apps schema, ready to serve. +// +// The generated schema models the API's self link as a typed nested object. It is +// no use to anyone in Terraform, so both app data sources drop it rather than +// carry it in their models. +// +// TODO(INF-7992): delete this strip once the spec marks _links opaque or omits +// it. +func appsDataSourceSchema(ctx context.Context) schema.Schema { + s := datasource_apps.AppsDataSourceSchema(ctx) + stripSetNestedCustomTypes(&s, "apps") + + set := s.Attributes["apps"].(schema.SetNestedAttribute) + delete(set.NestedObject.Attributes, "_links") + s.Attributes["apps"] = set + + return s +} + +// --- ably_apps ------------------------------------------------------------- + +type DataSourceApps struct { + p *AblyProvider +} + +var _ datasource.DataSource = &DataSourceApps{} + +func (d DataSourceApps) Metadata(ctx context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = "ably_apps" +} + +// Schema defines the schema for the data source. +// +// GENERATED SCHEMA: the attribute set, types, nesting, sensitivity and +// descriptions come from internal/provider/codegen, produced by `make generate` +// from the Control API spec. +func (d DataSourceApps) Schema(ctx context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) { + s := appsDataSourceSchema(ctx) + + // The provider already knows the account, so asking again is noise. + optionalString(s.Attributes, "account_id", + "The account to list apps for. Defaults to the account the provider's token belongs to.") + + s.MarkdownDescription = "The `ably_apps` data source lists every Ably app in an account, including apps Terraform does not manage. Use `ably_app` to look up a single app by id or name." + + resp.Schema = s +} + +func (d DataSourceApps) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { + if !d.p.ensureConfigured(&resp.Diagnostics) { + return + } + + var config AblyAppsDataSourceModel + resp.Diagnostics.Append(req.Config.Get(ctx, &config)...) + if resp.Diagnostics.HasError() { + return + } + + accountID := dataSourceAccountID(d.p, config.AccountID) + apps, err := d.p.client.ListApps(ctx, accountID) + if err != nil { + readDataSourceError(&resp.Diagnostics, "ably_apps", err) + return + } + + state := AblyAppsDataSourceModel{ + AccountID: types.StringValue(accountID), + Apps: make([]AblyAppDataSourceModel, 0, len(apps)), + } + for _, app := range apps { + state.Apps = append(state.Apps, appDataSourceModel(app)) + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) +} + +// --- ably_app -------------------------------------------------------------- + +type DataSourceApp struct { + p *AblyProvider +} + +var _ datasource.DataSource = &DataSourceApp{} + +func (d DataSourceApp) Metadata(ctx context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = "ably_app" +} + +// Schema defines the schema for the data source. The attributes are lifted from +// the generated ably_apps element, so this schema tracks the spec too. +func (d DataSourceApp) Schema(ctx context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) { + attributes := elementAttributes(appsDataSourceSchema(ctx), "apps") + + optionalString(attributes, "id", "The app ID. Set either this or name.") + optionalString(attributes, "name", "The app name. Set either this or id. Names are not unique, so a name matching more than one app is an error.") + optionalString(attributes, "account_id", "The account to search. Defaults to the account the provider's token belongs to.") + + resp.Schema = schema.Schema{ + Attributes: attributes, + MarkdownDescription: "The `ably_app` data source looks up a single Ably app by id or name, so you can reference an app this Terraform configuration does not manage. The Control API has no fetch-by-id endpoint for apps, so this lists the account's apps and matches locally.", + } +} + +func (d DataSourceApp) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { + if !d.p.ensureConfigured(&resp.Diagnostics) { + return + } + + var config AblyAppDataSourceModel + resp.Diagnostics.Append(req.Config.Get(ctx, &config)...) + if resp.Diagnostics.HasError() { + return + } + + accountID := dataSourceAccountID(d.p, config.AccountID) + apps, err := d.p.client.ListApps(ctx, accountID) + if err != nil { + readDataSourceError(&resp.Diagnostics, "ably_app", err) + return + } + + app, diags := findOne( + lookup{dataSourceName: "ably_app", id: config.ID, name: config.Name}, + apps, + func(a control.AppResponse) string { return a.ID }, + func(a control.AppResponse) string { return a.Name }, + ) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + state := appDataSourceModel(app) + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) +} diff --git a/internal/provider/data_source_ably_app_test.go b/internal/provider/data_source_ably_app_test.go new file mode 100644 index 0000000..1e0ccc7 --- /dev/null +++ b/internal/provider/data_source_ably_app_test.go @@ -0,0 +1,118 @@ +// Package provider implements the Ably provider for Terraform +package provider + +import ( + "fmt" + "regexp" + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/acctest" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" +) + +// TestAccAblyAppDataSource covers looking an app up by both keys, and the plural +// list, against an app the same config creates. +func TestAccAblyAppDataSource(t *testing.T) { + appName := acctest.RandStringFromCharSet(15, acctest.CharSetAlphaNum) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: testAccAblyAppDataSourceConfig(appName), + Check: resource.ComposeAggregateTestCheckFunc( + // By id: every attribute comes from the API, so the data + // source and the resource must agree. + resource.TestCheckResourceAttrPair( + "data.ably_app.by_id", "id", "ably_app.app0", "id"), + resource.TestCheckResourceAttr("data.ably_app.by_id", "name", appName), + resource.TestCheckResourceAttr("data.ably_app.by_id", "status", "enabled"), + resource.TestCheckResourceAttr("data.ably_app.by_id", "tls_only", "true"), + // By name: same app, found the other way. + resource.TestCheckResourceAttrPair( + "data.ably_app.by_name", "id", "ably_app.app0", "id"), + // account_id defaults to the provider's account rather than + // having to be passed in. + resource.TestCheckResourceAttrSet("data.ably_app.by_id", "account_id"), + // The plural data source lists at least the app we made. + resource.TestCheckResourceAttrSet("data.ably_apps.all", "apps.#"), + ), + }, + }, + }) +} + +// TestAccAblyAppDataSourceLookupErrors covers the three ways a lookup fails. +// Setting both keys, and setting neither, are caught before any API call. A id +// that matches nothing is only knowable after listing the account's apps. All +// three have to say which mistake was made rather than surface an opaque API +// error. +func TestAccAblyAppDataSourceLookupErrors(t *testing.T) { + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: testAccAblyAppDataSourceLookupConfig(`id = "abc123"` + "\n" + `name = "some-app"`), + ExpectError: regexp.MustCompile(`Ambiguous ably_app lookup`), + }, + { + Config: testAccAblyAppDataSourceLookupConfig(""), + ExpectError: regexp.MustCompile(`Incomplete ably_app lookup`), + }, + { + Config: testAccAblyAppDataSourceLookupConfig(`id = "does-not-exist"`), + ExpectError: regexp.MustCompile(`No ably_app with id "does-not-exist"`), + }, + }, + }) +} + +func testAccAblyAppDataSourceConfig(appName string) string { + return fmt.Sprintf(` +terraform { + required_providers { + ably = { + source = "registry.terraform.io/ably/ably" + } + } +} +provider "ably" {} + +resource "ably_app" "app0" { + name = %[1]q + status = "enabled" + tls_only = true +} + +data "ably_app" "by_id" { + id = ably_app.app0.id +} + +data "ably_app" "by_name" { + name = ably_app.app0.name +} + +data "ably_apps" "all" { + depends_on = [ably_app.app0] +} +`, appName) +} + +func testAccAblyAppDataSourceLookupConfig(lookupAttrs string) string { + return fmt.Sprintf(` +terraform { + required_providers { + ably = { + source = "registry.terraform.io/ably/ably" + } + } +} +provider "ably" {} + +data "ably_app" "lookup" { + %[1]s +} +`, lookupAttrs) +} diff --git a/internal/provider/data_source_ably_key.go b/internal/provider/data_source_ably_key.go new file mode 100644 index 0000000..71171bc --- /dev/null +++ b/internal/provider/data_source_ably_key.go @@ -0,0 +1,215 @@ +// Package provider implements the Ably provider for Terraform +package provider + +import ( + "context" + + "github.com/ably/terraform-provider-ably/control" + "github.com/ably/terraform-provider-ably/internal/provider/codegen/datasource_keys" + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +// AblyKeyDataSourceModel is one API key as the data sources report it. It serves +// both ably_api_key and the elements of ably_api_keys. +// +// The key attribute carries the complete API key including its secret, and is +// marked sensitive by the generator's shared sensitive-name set (see +// codegen/ruletypesgen). Anything reading this data source is reading a +// credential. +type AblyKeyDataSourceModel struct { + ID types.String `tfsdk:"id"` + AppID types.String `tfsdk:"app_id"` + Name types.String `tfsdk:"name"` + Key types.String `tfsdk:"key"` + Status types.Int64 `tfsdk:"status"` + RevocableTokens types.Bool `tfsdk:"revocable_tokens"` + Capabilities types.Map `tfsdk:"capabilities"` + Created types.Int64 `tfsdk:"created"` + Modified types.Int64 `tfsdk:"modified"` +} + +// AblyKeysDataSourceModel is the model for the plural ably_api_keys data source. +type AblyKeysDataSourceModel struct { + AppID types.String `tfsdk:"app_id"` + Keys []AblyKeyDataSourceModel `tfsdk:"keys"` +} + +// keyDataSourceModel maps an API key onto the shared model. +func keyDataSourceModel(ctx context.Context, key control.KeyResponse) (AblyKeyDataSourceModel, diag.Diagnostics) { + capability, diags := capabilityToMap(ctx, key.Capability) + + return AblyKeyDataSourceModel{ + ID: types.StringValue(key.ID), + AppID: types.StringValue(key.AppID), + Name: types.StringValue(key.Name), + Key: types.StringValue(key.Key), + Status: types.Int64Value(int64(key.Status)), + RevocableTokens: optBoolValue(key.RevocableTokens), + Capabilities: capability, + Created: types.Int64Value(key.Created), + Modified: types.Int64Value(key.Modified), + }, diags +} + +// capabilityToMap converts the API's capability map into the tfsdk map of lists, +// returning null when the key has no capabilities rather than an empty map. +func capabilityToMap(ctx context.Context, capability map[string][]string) (types.Map, diag.Diagnostics) { + listType := types.ListType{ElemType: types.StringType} + if len(capability) == 0 { + return types.MapNull(listType), nil + } + return types.MapValueFrom(ctx, listType, capability) +} + +// keysDataSourceSchema returns the generated ably_api_keys schema, ready to serve. +// +// The API calls the permissions map `capability` and the generated schema follows +// it, but the ably_api_key resource has always exposed it as `capabilities`. Matching +// the resource matters more here than matching the spec's property name, so the +// attribute is renamed. It stays a map of lists rather than the resource's map of +// sets: the data source is read-only, so preserving the API's ordering costs +// nothing and needs no sorting plan modifier. +func keysDataSourceSchema(ctx context.Context) schema.Schema { + s := datasource_keys.KeysDataSourceSchema(ctx) + stripSetNestedCustomTypes(&s, "keys") + + set := s.Attributes["keys"].(schema.SetNestedAttribute) + if capability, ok := set.NestedObject.Attributes["capability"]; ok { + set.NestedObject.Attributes["capabilities"] = capability + delete(set.NestedObject.Attributes, "capability") + } + s.Attributes["keys"] = set + + return s +} + +// --- ably_api_keys ------------------------------------------------------------- + +type DataSourceKeys struct { + p *AblyProvider +} + +var _ datasource.DataSource = &DataSourceKeys{} + +func (d DataSourceKeys) Metadata(ctx context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = "ably_api_keys" +} + +// Schema defines the schema for the data source. +// +// GENERATED SCHEMA: the attribute set, types, nesting, sensitivity and +// descriptions come from internal/provider/codegen, produced by `make generate` +// from the Control API spec. +func (d DataSourceKeys) Schema(ctx context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) { + s := keysDataSourceSchema(ctx) + + s.MarkdownDescription = "The `ably_api_keys` data source lists every API key in an Ably app, including keys Terraform does not manage. Each key includes its secret, so treat anything derived from this data source as a credential. Use `ably_api_key` to look up a single key by id or name." + + resp.Schema = s +} + +func (d DataSourceKeys) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { + if !d.p.ensureConfigured(&resp.Diagnostics) { + return + } + + var config AblyKeysDataSourceModel + resp.Diagnostics.Append(req.Config.Get(ctx, &config)...) + if resp.Diagnostics.HasError() { + return + } + + keys, err := d.p.client.ListKeys(ctx, config.AppID.ValueString()) + if err != nil { + readDataSourceError(&resp.Diagnostics, "ably_api_keys", err) + return + } + + state := AblyKeysDataSourceModel{ + AppID: config.AppID, + Keys: make([]AblyKeyDataSourceModel, 0, len(keys)), + } + for _, key := range keys { + model, diags := keyDataSourceModel(ctx, key) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + state.Keys = append(state.Keys, model) + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) +} + +// --- ably_api_key -------------------------------------------------------------- + +type DataSourceKey struct { + p *AblyProvider +} + +var _ datasource.DataSource = &DataSourceKey{} + +func (d DataSourceKey) Metadata(ctx context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = "ably_api_key" +} + +// Schema defines the schema for the data source. The attributes are lifted from +// the generated ably_api_keys element, so this schema tracks the spec too. +func (d DataSourceKey) Schema(ctx context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) { + attributes := elementAttributes(keysDataSourceSchema(ctx), "keys") + + requireString(attributes, "app_id", "The Ably app the key belongs to.") + optionalString(attributes, "id", "The key ID. Set either this or name.") + optionalString(attributes, "name", "The key name. Set either this or id. Names are not unique, so a name matching more than one key is an error.") + + resp.Schema = schema.Schema{ + Attributes: attributes, + MarkdownDescription: "The `ably_api_key` data source looks up a single Ably API key by id or name, including its secret. The Control API has no fetch-by-id endpoint for keys, so this lists the app's keys and matches locally.", + } +} + +func (d DataSourceKey) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { + if !d.p.ensureConfigured(&resp.Diagnostics) { + return + } + + var config AblyKeyDataSourceModel + resp.Diagnostics.Append(req.Config.Get(ctx, &config)...) + if resp.Diagnostics.HasError() { + return + } + + keys, err := d.p.client.ListKeys(ctx, config.AppID.ValueString()) + if err != nil { + readDataSourceError(&resp.Diagnostics, "ably_api_key", err) + return + } + + key, diags := findOne( + lookup{dataSourceName: "ably_api_key", id: config.ID, name: config.Name}, + keys, + func(k control.KeyResponse) string { return k.ID }, + func(k control.KeyResponse) string { return k.Name }, + ) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + state, modelDiags := keyDataSourceModel(ctx, key) + resp.Diagnostics.Append(modelDiags...) + if resp.Diagnostics.HasError() { + return + } + // app_id is a configured value, so it has to come back exactly as given. + // control.KeyResponse tags AppID omitempty, so a response omitting it would + // otherwise write "" into state and fail with "provider produced + // inconsistent result". The plural data sources reuse config.AppID for the + // same reason. + state.AppID = config.AppID + + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) +} diff --git a/internal/provider/data_source_ably_key_test.go b/internal/provider/data_source_ably_key_test.go new file mode 100644 index 0000000..a96a49f --- /dev/null +++ b/internal/provider/data_source_ably_key_test.go @@ -0,0 +1,141 @@ +// Package provider implements the Ably provider for Terraform +package provider + +import ( + "fmt" + "regexp" + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/acctest" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" +) + +// TestAccAblyAPIKeyDataSource covers looking a key up by id and by name, and the +// plural list, against a key the same config creates. +func TestAccAblyAPIKeyDataSource(t *testing.T) { + appName := acctest.RandStringFromCharSet(15, acctest.CharSetAlphaNum) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: testAccAblyKeyDataSourceConfig(appName), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttrPair( + "data.ably_api_key.by_id", "id", "ably_api_key.key0", "id"), + resource.TestCheckResourceAttr("data.ably_api_key.by_id", "name", "key0"), + resource.TestCheckResourceAttr("data.ably_api_key.by_id", "capabilities.channel1.#", "2"), + // app_id is configured, so it has to come back as given rather + // than from the response, which may omit it. + resource.TestCheckResourceAttrPair( + "data.ably_api_key.by_id", "app_id", "ably_app.app0", "id"), + resource.TestCheckResourceAttrPair( + "data.ably_api_key.by_name", "id", "ably_api_key.key0", "id"), + resource.TestCheckResourceAttrSet("data.ably_api_keys.all", "keys.#"), + ), + }, + }, + }) +} + +// TestAccAblyAPIKeyDataSourceAmbiguousName is the regression test for the deliberate +// refusal to guess. Key names are not unique, so two keys sharing a name must be +// an error naming both ids, not an arbitrary pick that changes with API ordering. +func TestAccAblyAPIKeyDataSourceAmbiguousName(t *testing.T) { + appName := acctest.RandStringFromCharSet(15, acctest.CharSetAlphaNum) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: testAccAblyKeyDataSourceDuplicateConfig(appName), + ExpectError: regexp.MustCompile(`More than one ably_api_key named "twin"`), + }, + }, + }) +} + +func testAccAblyKeyDataSourceConfig(appName string) string { + return fmt.Sprintf(` +terraform { + required_providers { + ably = { + source = "registry.terraform.io/ably/ably" + } + } +} +provider "ably" {} + +resource "ably_app" "app0" { + name = %[1]q + status = "enabled" + tls_only = true +} + +resource "ably_api_key" "key0" { + app_id = ably_app.app0.id + name = "key0" + capabilities = { + "channel1" = ["publish", "subscribe"], + } +} + +data "ably_api_key" "by_id" { + app_id = ably_app.app0.id + id = ably_api_key.key0.id +} + +data "ably_api_key" "by_name" { + app_id = ably_app.app0.id + name = ably_api_key.key0.name +} + +data "ably_api_keys" "all" { + app_id = ably_app.app0.id + depends_on = [ably_api_key.key0] +} +`, appName) +} + +func testAccAblyKeyDataSourceDuplicateConfig(appName string) string { + return fmt.Sprintf(` +terraform { + required_providers { + ably = { + source = "registry.terraform.io/ably/ably" + } + } +} +provider "ably" {} + +resource "ably_app" "app0" { + name = %[1]q + status = "enabled" + tls_only = true +} + +resource "ably_api_key" "twin_a" { + app_id = ably_app.app0.id + name = "twin" + capabilities = { + "channel1" = ["publish"], + } +} + +resource "ably_api_key" "twin_b" { + app_id = ably_app.app0.id + name = "twin" + capabilities = { + "channel2" = ["subscribe"], + } +} + +data "ably_api_key" "ambiguous" { + app_id = ably_app.app0.id + name = "twin" + depends_on = [ably_api_key.twin_a, ably_api_key.twin_b] +} +`, appName) +} diff --git a/internal/provider/data_source_ably_me.go b/internal/provider/data_source_ably_me.go new file mode 100644 index 0000000..cf3b1cb --- /dev/null +++ b/internal/provider/data_source_ably_me.go @@ -0,0 +1,106 @@ +// Package provider implements the Ably provider for Terraform +package provider + +import ( + "context" + + "github.com/ably/terraform-provider-ably/internal/provider/codegen/datasource_me" + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +// AblyMeTokenModel mirrors control.MeToken. +type AblyMeTokenModel struct { + ID types.String `tfsdk:"id"` + Name types.String `tfsdk:"name"` + Capabilities types.List `tfsdk:"capabilities"` + ExpiresAt types.String `tfsdk:"expires_at"` + LastUsedAt types.String `tfsdk:"last_used_at"` +} + +// AblyMeUserModel mirrors control.MeUser. +type AblyMeUserModel struct { + ID types.Int64 `tfsdk:"id"` + Email types.String `tfsdk:"email"` +} + +// AblyMeAccountModel mirrors control.MeAccount. +type AblyMeAccountModel struct { + ID types.String `tfsdk:"id"` + Name types.String `tfsdk:"name"` +} + +// AblyMeDataSourceModel is the model for the ably_me data source. +type AblyMeDataSourceModel struct { + Token *AblyMeTokenModel `tfsdk:"token"` + User *AblyMeUserModel `tfsdk:"user"` + Account *AblyMeAccountModel `tfsdk:"account"` +} + +type DataSourceMe struct { + p *AblyProvider +} + +var _ datasource.DataSource = &DataSourceMe{} + +func (d DataSourceMe) Metadata(ctx context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = "ably_me" +} + +// Schema defines the schema for the data source. +// +// GENERATED SCHEMA: everything comes from internal/provider/codegen, produced by +// `make generate` from the Control API spec. +func (d DataSourceMe) Schema(ctx context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) { + s := datasource_me.MeDataSourceSchema(ctx) + stripDataSourceCustomTypes(s.Attributes) + + s.MarkdownDescription = "The `ably_me` data source describes the token the provider is configured with, and the account and user it belongs to. It is the way to reference your account ID without hardcoding it." + + resp.Schema = s +} + +func (d DataSourceMe) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { + if !d.p.ensureConfigured(&resp.Diagnostics) { + return + } + + me, err := d.p.client.Me(ctx) + if err != nil { + readDataSourceError(&resp.Diagnostics, "ably_me", err) + return + } + + var state AblyMeDataSourceModel + + // Every block is nullable: which of them come back depends on the token type + // and its capabilities, so a missing block is normal rather than an error. + if me.Token != nil { + capabilities, diags := stringsToList(ctx, me.Token.Capabilities) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + state.Token = &AblyMeTokenModel{ + ID: types.StringValue(me.Token.ID), + Name: types.StringValue(me.Token.Name), + Capabilities: capabilities, + ExpiresAt: optStringValue(me.Token.ExpiresAt), + LastUsedAt: optStringValue(me.Token.LastUsedAt), + } + } + if me.User != nil { + state.User = &AblyMeUserModel{ + ID: types.Int64Value(int64(me.User.ID)), + Email: types.StringValue(me.User.Email), + } + } + if me.Account != nil { + state.Account = &AblyMeAccountModel{ + ID: types.StringValue(me.Account.ID), + Name: types.StringValue(me.Account.Name), + } + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) +} diff --git a/internal/provider/data_source_ably_namespace.go b/internal/provider/data_source_ably_namespace.go new file mode 100644 index 0000000..d3be8c2 --- /dev/null +++ b/internal/provider/data_source_ably_namespace.go @@ -0,0 +1,220 @@ +// Package provider implements the Ably provider for Terraform +package provider + +import ( + "context" + + "github.com/ably/terraform-provider-ably/control" + "github.com/ably/terraform-provider-ably/internal/provider/codegen/datasource_namespaces" + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +// AblyNamespaceDataSourceModel is one namespace (channel rule) as the data +// sources report it. It serves both ably_namespace and the elements of +// ably_namespaces. +type AblyNamespaceDataSourceModel struct { + ID types.String `tfsdk:"id"` + AppID types.String `tfsdk:"app_id"` + Identified types.Bool `tfsdk:"identified"` + Authenticated types.Bool `tfsdk:"authenticated"` + Persisted types.Bool `tfsdk:"persisted"` + PersistLast types.Bool `tfsdk:"persist_last"` + PushEnabled types.Bool `tfsdk:"push_enabled"` + TLSOnly types.Bool `tfsdk:"tls_only"` + ExposeTimeserial types.Bool `tfsdk:"expose_timeserial"` + MutableMessages types.Bool `tfsdk:"mutable_messages"` + PopulateChannelRegistry types.Bool `tfsdk:"populate_channel_registry"` + BatchingEnabled types.Bool `tfsdk:"batching_enabled"` + BatchingInterval types.Int64 `tfsdk:"batching_interval"` + ConflationEnabled types.Bool `tfsdk:"conflation_enabled"` + ConflationInterval types.Int64 `tfsdk:"conflation_interval"` + ConflationKey types.String `tfsdk:"conflation_key"` + Created types.Int64 `tfsdk:"created"` + Modified types.Int64 `tfsdk:"modified"` +} + +// AblyNamespacesDataSourceModel is the model for the plural ably_namespaces data +// source. +type AblyNamespacesDataSourceModel struct { + AppID types.String `tfsdk:"app_id"` + Namespaces []AblyNamespaceDataSourceModel `tfsdk:"namespaces"` +} + +// namespaceDataSourceModel maps an API namespace onto the shared model. +func namespaceDataSourceModel(namespace control.NamespaceResponse) AblyNamespaceDataSourceModel { + return AblyNamespaceDataSourceModel{ + ID: types.StringValue(namespace.ID), + AppID: types.StringValue(namespace.AppID), + // identified and authenticated are two names for one platform setting, so + // both report the same effective value rather than whichever field this + // API deployment happened to populate. Reporting authenticated as false + // next to identified as true would be a lie about the namespace. + Identified: types.BoolValue(namespaceIdentifiedValue(namespace)), + Authenticated: types.BoolValue(namespaceIdentifiedValue(namespace)), + Persisted: types.BoolValue(namespace.Persisted), + PersistLast: types.BoolValue(namespace.PersistLast), + PushEnabled: types.BoolValue(namespace.PushEnabled), + TLSOnly: types.BoolValue(namespace.TLSOnly), + ExposeTimeserial: types.BoolValue(namespace.ExposeTimeserial), + MutableMessages: types.BoolValue(namespace.MutableMessages), + PopulateChannelRegistry: types.BoolValue(namespace.PopulateChannelRegistry), + BatchingEnabled: optBoolValue(namespace.BatchingEnabled), + BatchingInterval: optIntValue(namespace.BatchingInterval), + ConflationEnabled: optBoolValue(namespace.ConflationEnabled), + ConflationInterval: optIntValue(namespace.ConflationInterval), + ConflationKey: optStringValue(namespace.ConflationKey), + Created: types.Int64Value(namespace.Created), + Modified: types.Int64Value(namespace.Modified), + } +} + +// namespacesDataSourceSchema returns the generated ably_namespaces schema, ready +// to serve. +// +// The vendored spec documents only the deprecated authenticated flag, not the +// canonical identified one the API now returns, so identified is added here. +// +// TODO(INF-7992): delete this addition once the spec documents identified; it +// then comes from the generated schema. +func namespacesDataSourceSchema(ctx context.Context) schema.Schema { + s := datasource_namespaces.NamespacesDataSourceSchema(ctx) + stripSetNestedCustomTypes(&s, "namespaces") + + set := s.Attributes["namespaces"].(schema.SetNestedAttribute) + set.NestedObject.Attributes["identified"] = schema.BoolAttribute{ + Computed: true, + Description: identifiedDescription, + MarkdownDescription: identifiedDescription, + } + if authenticated, ok := set.NestedObject.Attributes["authenticated"].(schema.BoolAttribute); ok { + authenticated.DeprecationMessage = "Use identified instead; authenticated is the deprecated alias for the same flag." + set.NestedObject.Attributes["authenticated"] = authenticated + } + s.Attributes["namespaces"] = set + + return s +} + +const identifiedDescription = "If `true`, clients are not permitted to use any channel in this namespace unless they are identified, that is, authenticated with a client ID. This is the canonical name for the flag the API also reports as `authenticated`." + +// --- ably_namespaces ------------------------------------------------------- + +type DataSourceNamespaces struct { + p *AblyProvider +} + +var _ datasource.DataSource = &DataSourceNamespaces{} + +func (d DataSourceNamespaces) Metadata(ctx context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = "ably_namespaces" +} + +// Schema defines the schema for the data source. +// +// GENERATED SCHEMA: the attribute set, types, nesting and descriptions come from +// internal/provider/codegen, produced by `make generate` from the Control API +// spec, plus the identified flag the spec does not carry yet. +func (d DataSourceNamespaces) Schema(ctx context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) { + s := namespacesDataSourceSchema(ctx) + + s.MarkdownDescription = "The `ably_namespaces` data source lists every namespace (channel rule) in an Ably app, including namespaces Terraform does not manage. Use `ably_namespace` to look up a single namespace by id." + + resp.Schema = s +} + +func (d DataSourceNamespaces) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { + if !d.p.ensureConfigured(&resp.Diagnostics) { + return + } + + var config AblyNamespacesDataSourceModel + resp.Diagnostics.Append(req.Config.Get(ctx, &config)...) + if resp.Diagnostics.HasError() { + return + } + + namespaces, err := d.p.client.ListNamespaces(ctx, config.AppID.ValueString()) + if err != nil { + readDataSourceError(&resp.Diagnostics, "ably_namespaces", err) + return + } + + state := AblyNamespacesDataSourceModel{ + AppID: config.AppID, + Namespaces: make([]AblyNamespaceDataSourceModel, 0, len(namespaces)), + } + for _, namespace := range namespaces { + state.Namespaces = append(state.Namespaces, namespaceDataSourceModel(namespace)) + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) +} + +// --- ably_namespace -------------------------------------------------------- + +type DataSourceNamespace struct { + p *AblyProvider +} + +var _ datasource.DataSource = &DataSourceNamespace{} + +func (d DataSourceNamespace) Metadata(ctx context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = "ably_namespace" +} + +// Schema defines the schema for the data source. The attributes are lifted from +// the generated ably_namespaces element, so this schema tracks the spec too. +// +// Namespaces have no name: the id (the channel prefix, e.g. "chat") is the name, +// so this looks up by id only. +func (d DataSourceNamespace) Schema(ctx context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) { + attributes := elementAttributes(namespacesDataSourceSchema(ctx), "namespaces") + + requireString(attributes, "app_id", "The Ably app the namespace belongs to.") + requireString(attributes, "id", "The namespace ID, which is the channel name prefix (for example `chat`).") + + resp.Schema = schema.Schema{ + Attributes: attributes, + MarkdownDescription: "The `ably_namespace` data source looks up a single Ably namespace (channel rule) by id, so you can read the settings of a namespace this Terraform configuration does not manage. The Control API has no fetch-by-id endpoint for namespaces, so this lists the app's namespaces and matches locally.", + } +} + +func (d DataSourceNamespace) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { + if !d.p.ensureConfigured(&resp.Diagnostics) { + return + } + + var config AblyNamespaceDataSourceModel + resp.Diagnostics.Append(req.Config.Get(ctx, &config)...) + if resp.Diagnostics.HasError() { + return + } + + namespaces, err := d.p.client.ListNamespaces(ctx, config.AppID.ValueString()) + if err != nil { + readDataSourceError(&resp.Diagnostics, "ably_namespace", err) + return + } + + namespace, diags := findOne( + lookup{dataSourceName: "ably_namespace", id: config.ID}, + namespaces, + func(n control.NamespaceResponse) string { return n.ID }, + func(n control.NamespaceResponse) string { return n.ID }, + ) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + state := namespaceDataSourceModel(namespace) + // app_id is a configured value, so it has to come back exactly as given. + // control.NamespaceResponse tags AppID omitempty, so a response omitting it would + // otherwise write "" into state and fail with "provider produced + // inconsistent result". The plural data sources reuse config.AppID for the + // same reason. + state.AppID = config.AppID + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) +} diff --git a/internal/provider/data_source_ably_namespace_test.go b/internal/provider/data_source_ably_namespace_test.go new file mode 100644 index 0000000..b8ce836 --- /dev/null +++ b/internal/provider/data_source_ably_namespace_test.go @@ -0,0 +1,78 @@ +// Package provider implements the Ably provider for Terraform +package provider + +import ( + "fmt" + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/acctest" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" +) + +// TestAccAblyNamespaceDataSource covers the singular lookup and the plural list +// against a namespace the same config creates. +// +// It also pins the identified/authenticated pair: the vendored spec documents only +// the deprecated authenticated flag, so identified is added to the data source +// schema by hand and must report the same value. +func TestAccAblyNamespaceDataSource(t *testing.T) { + appName := acctest.RandStringFromCharSet(15, acctest.CharSetAlphaNum) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: testAccAblyNamespaceDataSourceConfig(appName), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("data.ably_namespace.by_id", "id", "chat"), + resource.TestCheckResourceAttr("data.ably_namespace.by_id", "persisted", "true"), + // app_id is configured, so it has to come back as given rather + // than from the response, which may omit it. + resource.TestCheckResourceAttrPair( + "data.ably_namespace.by_id", "app_id", "ably_app.app0", "id"), + resource.TestCheckResourceAttr("data.ably_namespace.by_id", "identified", "true"), + resource.TestCheckResourceAttr("data.ably_namespace.by_id", "authenticated", "true"), + resource.TestCheckResourceAttr("data.ably_namespace.by_id", "expose_timeserial", "false"), + resource.TestCheckResourceAttrSet("data.ably_namespaces.all", "namespaces.#"), + ), + }, + }, + }) +} + +func testAccAblyNamespaceDataSourceConfig(appName string) string { + return fmt.Sprintf(` +terraform { + required_providers { + ably = { + source = "registry.terraform.io/ably/ably" + } + } +} +provider "ably" {} + +resource "ably_app" "app0" { + name = %[1]q + status = "enabled" + tls_only = true +} + +resource "ably_namespace" "chat" { + app_id = ably_app.app0.id + id = "chat" + persisted = true + identified = true +} + +data "ably_namespace" "by_id" { + app_id = ably_app.app0.id + id = ably_namespace.chat.id +} + +data "ably_namespaces" "all" { + app_id = ably_app.app0.id + depends_on = [ably_namespace.chat] +} +`, appName) +} diff --git a/internal/provider/data_source_ably_queue.go b/internal/provider/data_source_ably_queue.go new file mode 100644 index 0000000..e2213b2 --- /dev/null +++ b/internal/provider/data_source_ably_queue.go @@ -0,0 +1,228 @@ +// Package provider implements the Ably provider for Terraform +package provider + +import ( + "context" + + "github.com/ably/terraform-provider-ably/control" + "github.com/ably/terraform-provider-ably/internal/provider/codegen/datasource_queues" + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +// AblyQueueAMQPDataSourceModel mirrors control.QueueAMQP. +type AblyQueueAMQPDataSourceModel struct { + URI types.String `tfsdk:"uri"` + QueueName types.String `tfsdk:"queue_name"` +} + +// AblyQueueStompDataSourceModel mirrors control.QueueStomp. +type AblyQueueStompDataSourceModel struct { + URI types.String `tfsdk:"uri"` + Host types.String `tfsdk:"host"` + Destination types.String `tfsdk:"destination"` +} + +// AblyQueueMessagesDataSourceModel mirrors control.QueueMessages. +type AblyQueueMessagesDataSourceModel struct { + Ready types.Int64 `tfsdk:"ready"` + Unacknowledged types.Int64 `tfsdk:"unacknowledged"` + Total types.Int64 `tfsdk:"total"` +} + +// AblyQueueStatsDataSourceModel mirrors control.QueueStats. +type AblyQueueStatsDataSourceModel struct { + PublishRate types.Float64 `tfsdk:"publish_rate"` + DeliveryRate types.Float64 `tfsdk:"delivery_rate"` + AcknowledgementRate types.Float64 `tfsdk:"acknowledgement_rate"` +} + +// AblyQueueDataSourceModel is one queue as the data sources report it. It serves +// both ably_queue and the elements of ably_queues. +// +// Unlike the ably_queue resource, which flattens the API's amqp and stomp objects +// into amqp_uri/stomp_uri and drops the live counters, the data sources carry the +// API's own shape: they are read-only, so there is no state compatibility to +// preserve and no reason to hide the messages and stats blocks. +type AblyQueueDataSourceModel struct { + ID types.String `tfsdk:"id"` + AppID types.String `tfsdk:"app_id"` + Name types.String `tfsdk:"name"` + Region types.String `tfsdk:"region"` + State types.String `tfsdk:"state"` + TTL types.Int64 `tfsdk:"ttl"` + MaxLength types.Int64 `tfsdk:"max_length"` + Deadletter types.Bool `tfsdk:"deadletter"` + DeadletterID types.String `tfsdk:"deadletter_id"` + AMQP *AblyQueueAMQPDataSourceModel `tfsdk:"amqp"` + Stomp *AblyQueueStompDataSourceModel `tfsdk:"stomp"` + Messages *AblyQueueMessagesDataSourceModel `tfsdk:"messages"` + Stats *AblyQueueStatsDataSourceModel `tfsdk:"stats"` +} + +// AblyQueuesDataSourceModel is the model for the plural ably_queues data source. +type AblyQueuesDataSourceModel struct { + AppID types.String `tfsdk:"app_id"` + Queues []AblyQueueDataSourceModel `tfsdk:"queues"` +} + +// queueDataSourceModel maps an API queue onto the shared model. +func queueDataSourceModel(queue control.QueueResponse) AblyQueueDataSourceModel { + return AblyQueueDataSourceModel{ + ID: types.StringValue(queue.ID), + AppID: types.StringValue(queue.AppID), + Name: types.StringValue(queue.Name), + Region: types.StringValue(queue.Region), + State: stringOrNull(queue.State), + TTL: types.Int64Value(int64(queue.TTL)), + MaxLength: types.Int64Value(int64(queue.MaxLength)), + Deadletter: types.BoolValue(queue.Deadletter), + DeadletterID: optStringValue(queue.DeadletterID), + AMQP: &AblyQueueAMQPDataSourceModel{ + URI: stringOrNull(queue.AMQP.URI), + QueueName: stringOrNull(queue.AMQP.QueueName), + }, + Stomp: &AblyQueueStompDataSourceModel{ + URI: stringOrNull(queue.Stomp.URI), + Host: stringOrNull(queue.Stomp.Host), + Destination: stringOrNull(queue.Stomp.Destination), + }, + Messages: &AblyQueueMessagesDataSourceModel{ + Ready: optIntValue(queue.Messages.Ready), + Unacknowledged: optIntValue(queue.Messages.Unacknowledged), + Total: optIntValue(queue.Messages.Total), + }, + Stats: &AblyQueueStatsDataSourceModel{ + PublishRate: optFloat64Value(queue.Stats.PublishRate), + DeliveryRate: optFloat64Value(queue.Stats.DeliveryRate), + AcknowledgementRate: optFloat64Value(queue.Stats.AcknowledgementRate), + }, + } +} + +// queuesDataSourceSchema returns the generated ably_queues schema, ready to +// serve. +func queuesDataSourceSchema(ctx context.Context) schema.Schema { + s := datasource_queues.QueuesDataSourceSchema(ctx) + stripSetNestedCustomTypes(&s, "queues") + return s +} + +// --- ably_queues ----------------------------------------------------------- + +type DataSourceQueues struct { + p *AblyProvider +} + +var _ datasource.DataSource = &DataSourceQueues{} + +func (d DataSourceQueues) Metadata(ctx context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = "ably_queues" +} + +// Schema defines the schema for the data source. +// +// GENERATED SCHEMA: the attribute set, types, nesting and descriptions come from +// internal/provider/codegen, produced by `make generate` from the Control API +// spec. +func (d DataSourceQueues) Schema(ctx context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) { + s := queuesDataSourceSchema(ctx) + + s.MarkdownDescription = "The `ably_queues` data source lists every queue in an Ably app, including queues Terraform does not manage, with their connection details and live message counts. Use `ably_queue` to look up a single queue by id or name." + + resp.Schema = s +} + +func (d DataSourceQueues) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { + if !d.p.ensureConfigured(&resp.Diagnostics) { + return + } + + var config AblyQueuesDataSourceModel + resp.Diagnostics.Append(req.Config.Get(ctx, &config)...) + if resp.Diagnostics.HasError() { + return + } + + queues, err := d.p.client.ListQueues(ctx, config.AppID.ValueString()) + if err != nil { + readDataSourceError(&resp.Diagnostics, "ably_queues", err) + return + } + + state := AblyQueuesDataSourceModel{ + AppID: config.AppID, + Queues: make([]AblyQueueDataSourceModel, 0, len(queues)), + } + for _, queue := range queues { + state.Queues = append(state.Queues, queueDataSourceModel(queue)) + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) +} + +// --- ably_queue ------------------------------------------------------------ + +type DataSourceQueue struct { + p *AblyProvider +} + +var _ datasource.DataSource = &DataSourceQueue{} + +func (d DataSourceQueue) Metadata(ctx context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = "ably_queue" +} + +// Schema defines the schema for the data source. The attributes are lifted from +// the generated ably_queues element, so this schema tracks the spec too. +func (d DataSourceQueue) Schema(ctx context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) { + attributes := elementAttributes(queuesDataSourceSchema(ctx), "queues") + + requireString(attributes, "app_id", "The Ably app the queue belongs to.") + optionalString(attributes, "id", "The queue ID. Set either this or name.") + optionalString(attributes, "name", "The queue name. Set either this or id. Names are not unique, so a name matching more than one queue is an error.") + + resp.Schema = schema.Schema{ + Attributes: attributes, + MarkdownDescription: "The `ably_queue` data source looks up a single Ably queue by id or name, with its AMQP and STOMP connection details. The Control API has no fetch-by-id endpoint for queues, so this lists the app's queues and matches locally.", + } +} + +func (d DataSourceQueue) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { + if !d.p.ensureConfigured(&resp.Diagnostics) { + return + } + + var config AblyQueueDataSourceModel + resp.Diagnostics.Append(req.Config.Get(ctx, &config)...) + if resp.Diagnostics.HasError() { + return + } + + queues, err := d.p.client.ListQueues(ctx, config.AppID.ValueString()) + if err != nil { + readDataSourceError(&resp.Diagnostics, "ably_queue", err) + return + } + + queue, diags := findOne( + lookup{dataSourceName: "ably_queue", id: config.ID, name: config.Name}, + queues, + func(q control.QueueResponse) string { return q.ID }, + func(q control.QueueResponse) string { return q.Name }, + ) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + state := queueDataSourceModel(queue) + // app_id is a configured value, so it has to come back exactly as given. + // control.QueueResponse tags AppID omitempty, so a response omitting it would + // otherwise write "" into state and fail with "provider produced + // inconsistent result". The plural data sources reuse config.AppID for the + // same reason. + state.AppID = config.AppID + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) +} diff --git a/internal/provider/data_source_ably_queue_test.go b/internal/provider/data_source_ably_queue_test.go new file mode 100644 index 0000000..1b6fdd8 --- /dev/null +++ b/internal/provider/data_source_ably_queue_test.go @@ -0,0 +1,87 @@ +// Package provider implements the Ably provider for Terraform +package provider + +import ( + "fmt" + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/acctest" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" +) + +// TestAccAblyQueueDataSource covers looking a queue up by id and by name, and the +// plural list. +// +// The data sources carry the API's own queue shape, so this also checks the +// nested amqp block the ably_queue resource flattens into amqp_uri. +func TestAccAblyQueueDataSource(t *testing.T) { + appName := acctest.RandStringFromCharSet(15, acctest.CharSetAlphaNum) + queueName := acctest.RandStringFromCharSet(10, acctest.CharSetAlphaNum) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: testAccAblyQueueDataSourceConfig(appName, queueName), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttrPair( + "data.ably_queue.by_id", "id", "ably_queue.queue0", "id"), + resource.TestCheckResourceAttr("data.ably_queue.by_id", "name", queueName), + resource.TestCheckResourceAttr("data.ably_queue.by_id", "region", "us-east-1-a"), + resource.TestCheckResourceAttr("data.ably_queue.by_id", "ttl", "60"), + resource.TestCheckResourceAttrSet("data.ably_queue.by_id", "amqp.uri"), + // app_id is configured, so it has to come back as given rather + // than from the response, which may omit it. + resource.TestCheckResourceAttrPair( + "data.ably_queue.by_id", "app_id", "ably_app.app0", "id"), + resource.TestCheckResourceAttrPair( + "data.ably_queue.by_name", "id", "ably_queue.queue0", "id"), + resource.TestCheckResourceAttrSet("data.ably_queues.all", "queues.#"), + ), + }, + }, + }) +} + +func testAccAblyQueueDataSourceConfig(appName, queueName string) string { + return fmt.Sprintf(` +terraform { + required_providers { + ably = { + source = "registry.terraform.io/ably/ably" + } + } +} +provider "ably" {} + +resource "ably_app" "app0" { + name = %[1]q + status = "enabled" + tls_only = true +} + +resource "ably_queue" "queue0" { + app_id = ably_app.app0.id + name = %[2]q + ttl = 60 + max_length = 10000 + region = "us-east-1-a" +} + +data "ably_queue" "by_id" { + app_id = ably_app.app0.id + id = ably_queue.queue0.id +} + +data "ably_queue" "by_name" { + app_id = ably_app.app0.id + name = ably_queue.queue0.name +} + +data "ably_queues" "all" { + app_id = ably_app.app0.id + depends_on = [ably_queue.queue0] +} +`, appName, queueName) +} diff --git a/internal/provider/data_sources.go b/internal/provider/data_sources.go new file mode 100644 index 0000000..550e74a --- /dev/null +++ b/internal/provider/data_sources.go @@ -0,0 +1,230 @@ +// Package provider implements the Ably provider for Terraform +package provider + +import ( + "context" + "fmt" + "sort" + + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +// This file holds the plumbing shared by the data sources. +// +// The Control API has no GET-by-ID for apps, keys, namespaces or queues; only +// rules have one. Everything else is a list endpoint, so the generated data +// sources are all plural: a parent path parameter plus a computed set of whole +// objects. The singular data sources are built from the same generated schema by +// lifting the set's element attributes to the top level, so there is one +// generated source of truth per entity and the two can't drift apart. Their Read +// lists and filters client side, which is what the resources already do (see +// resource_ably_app.go's Read and the NOTE on it). + +// elementAttributes returns a copy of the element attributes of a generated +// plural data source's collection, with the generated CustomTypes stripped so a +// plain-struct tfsdk model reflects cleanly. This is the attribute set a singular +// data source serves. +func elementAttributes(s schema.Schema, collection string) map[string]schema.Attribute { + set, ok := s.Attributes[collection].(schema.SetNestedAttribute) + if !ok { + return map[string]schema.Attribute{} + } + + attributes := make(map[string]schema.Attribute, len(set.NestedObject.Attributes)) + for name, attribute := range set.NestedObject.Attributes { + attributes[name] = attribute + } + stripDataSourceCustomTypes(attributes) + return attributes +} + +// stripSetNestedCustomTypes strips the generated CustomTypes from a plural data +// source's collection attribute and everything nested inside it. +func stripSetNestedCustomTypes(s *schema.Schema, collection string) { + set, ok := s.Attributes[collection].(schema.SetNestedAttribute) + if !ok { + return + } + set.CustomType = nil + set.NestedObject.CustomType = nil + stripDataSourceCustomTypes(set.NestedObject.Attributes) + s.Attributes[collection] = set +} + +// stripDataSourceCustomTypes strips generated CustomTypes from an attribute map, +// recursing into nested blocks. +func stripDataSourceCustomTypes(attributes map[string]schema.Attribute) { + for name, attribute := range attributes { + switch typed := attribute.(type) { + case schema.SingleNestedAttribute: + typed.CustomType = nil + stripDataSourceCustomTypes(typed.Attributes) + attributes[name] = typed + case schema.SetNestedAttribute: + typed.CustomType = nil + typed.NestedObject.CustomType = nil + stripDataSourceCustomTypes(typed.NestedObject.Attributes) + attributes[name] = typed + case schema.ListNestedAttribute: + typed.CustomType = nil + typed.NestedObject.CustomType = nil + stripDataSourceCustomTypes(typed.NestedObject.Attributes) + attributes[name] = typed + } + } +} + +// requireString makes a generated computed string attribute a required argument, +// for a parent identifier a singular lookup can't work without. +func requireString(attributes map[string]schema.Attribute, name, description string) { + attribute, ok := attributes[name].(schema.StringAttribute) + if !ok { + return + } + attribute.Required = true + attribute.Computed = false + attribute.Optional = false + if description != "" { + attribute.Description = description + attribute.MarkdownDescription = description + } + attributes[name] = attribute +} + +// optionalString makes a generated computed string attribute an optional +// argument that is still computed, which is the shape of a lookup key: set it to +// search by it, leave it out and it comes back from the API. +func optionalString(attributes map[string]schema.Attribute, name, description string) { + attribute, ok := attributes[name].(schema.StringAttribute) + if !ok { + return + } + attribute.Optional = true + attribute.Computed = true + // The generated plural schemas mark the parent path parameter Required, and + // the framework rejects Required alongside Optional/Computed. + attribute.Required = false + if description != "" { + attribute.Description = description + attribute.MarkdownDescription = description + } + attributes[name] = attribute +} + +// lookup identifies which record a singular data source wants. Exactly one of id +// or name must be set: the Control API has no lookup endpoint for these entities, +// so a data source lists them and matches locally. +type lookup struct { + // dataSourceName is the Terraform type name, for diagnostics. + dataSourceName string + // id and name are the caller's arguments; exactly one carries a value. + id, name types.String +} + +// validate checks that exactly one lookup key was given. +func (l lookup) validate() diag.Diagnostics { + var diags diag.Diagnostics + + hasID := !l.id.IsNull() && l.id.ValueString() != "" + hasName := !l.name.IsNull() && l.name.ValueString() != "" + + switch { + case hasID && hasName: + diags.AddError( + fmt.Sprintf("Ambiguous %s lookup", l.dataSourceName), + "Set either id or name, not both.", + ) + case !hasID && !hasName: + diags.AddError( + fmt.Sprintf("Incomplete %s lookup", l.dataSourceName), + "Set either id or name to say which one you want.", + ) + } + + return diags +} + +// findOne picks the single record matching the lookup out of everything the API +// returned. +// +// Names are not unique in the Control API, so a name that matches more than once +// is an error rather than a silent pick: returning an arbitrary match would make +// the config's meaning depend on API ordering. The error lists the IDs so the +// caller can switch to looking up by id. +func findOne[T any](l lookup, records []T, idOf, nameOf func(T) string) (T, diag.Diagnostics) { + var zero T + + diags := l.validate() + if diags.HasError() { + return zero, diags + } + + if id := l.id.ValueString(); id != "" { + for _, record := range records { + if idOf(record) == id { + return record, diags + } + } + diags.AddError( + fmt.Sprintf("No %s with id %q", l.dataSourceName, id), + fmt.Sprintf("Found %d record(s), none with that id. Check the id, and that the token has access to it.", len(records)), + ) + return zero, diags + } + + name := l.name.ValueString() + var matches []T + var matchedIDs []string + for _, record := range records { + if nameOf(record) == name { + matches = append(matches, record) + matchedIDs = append(matchedIDs, idOf(record)) + } + } + + switch len(matches) { + case 1: + return matches[0], diags + case 0: + diags.AddError( + fmt.Sprintf("No %s named %q", l.dataSourceName, name), + fmt.Sprintf("Found %d record(s), none with that name.", len(records)), + ) + default: + sort.Strings(matchedIDs) + diags.AddError( + fmt.Sprintf("More than one %s named %q", l.dataSourceName, name), + fmt.Sprintf("Names are not unique in the Control API and %d records share this one (ids: %v). Look it up by id instead.", len(matches), matchedIDs), + ) + } + + return zero, diags +} + +// readDataSourceError is the diagnostic for a failed Control API read, worded the +// same way for every data source. +func readDataSourceError(diags *diag.Diagnostics, dataSourceName string, err error) { + diags.AddError( + fmt.Sprintf("Error reading %s", dataSourceName), + fmt.Sprintf("Could not read %s, unexpected error: %s", dataSourceName, err.Error()), + ) +} + +// dataSourceAccountID resolves the account the caller means: the account_id +// argument when set, otherwise the one the provider is configured with. +func dataSourceAccountID(p *AblyProvider, argument types.String) string { + if !argument.IsNull() && argument.ValueString() != "" { + return argument.ValueString() + } + return p.accountID +} + +// stringsToList converts a []string into a types.List of strings. +func stringsToList(ctx context.Context, values []string) (types.List, diag.Diagnostics) { + if values == nil { + return types.ListNull(types.StringType), nil + } + return types.ListValueFrom(ctx, types.StringType, values) +} diff --git a/internal/provider/fake_control_api_test.go b/internal/provider/fake_control_api_test.go index cd62af9..51d847a 100644 --- a/internal/provider/fake_control_api_test.go +++ b/internal/provider/fake_control_api_test.go @@ -2,8 +2,7 @@ // // This file provides an in-process, stateful stand-in for the Ably Control // API so the provider's acceptance tests can run with NO credentials and NO -// network access. It is the "Tier 1" hermetic loop described in -// CODEGEN_STRATEGY.md: the loop an AI agent (or CI on a fork) can run on every +// network access. It is the loop an AI agent (or CI on a fork) can run on every // change to prove the provider's CRUD/import/diff logic is internally // consistent. // @@ -457,6 +456,17 @@ func (f *fakeControlAPI) createRule(w http.ResponseWriter, r *http.Request) { t["format"] = "json" } } + // Before-publish AWS Lambda rules get a source whether or not one was sent: + // the real API assigns {"type": "chat.message"} when it is omitted (verified + // against staging, 2026-08-17). Mirroring it is worth the exception to this + // fake's echo-only rule, because the provider has to keep that value stable + // across plans, and without it here nothing catches a regression until a + // staging run does. + if body["ruleType"] == "aws/lambda/before-publish" { + if _, has := body["source"]; !has { + body["source"] = map[string]any{"type": "chat.message"} + } + } if f.rules[appID] == nil { f.rules[appID] = map[string]record{} } @@ -584,7 +594,7 @@ func TestMain(m *testing.M) { // pinned to the "ably/ably" namespace, which the in-process reattach factory // (keyed by the bare type "ably") cannot satisfy, and any ambient // ~/.terraformrc dev_overrides would otherwise run a stale installed binary -// (the trap that the Phase 0c spike and CODEGEN_STRATEGY.md call out). Building +// (a trap that has silently run the wrong binary before now). Building // here guarantees the tests exercise THIS code with no network install. // // It returns the temp directory it created so the caller can remove it. diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 57db2e9..8b5e3b3 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -262,6 +262,12 @@ func (p *AblyProvider) Resources(context.Context) []func() resource.Resource { func() resource.Resource { return ResourceRuleAMQP{p} }, func() resource.Resource { return ResourceRuleAMQPExternal{p} }, func() resource.Resource { return ResourceRuleBodyguard{p} }, + func() resource.Resource { return ResourceRuleTisane{p} }, + func() resource.Resource { return ResourceRuleAzureModeration{p} }, + func() resource.Resource { return ResourceRuleHiveText{p} }, + func() resource.Resource { return ResourceRuleHiveDashboard{p} }, + func() resource.Resource { return ResourceRuleBeforePublishWebhook{p} }, + func() resource.Resource { return ResourceRuleBeforePublishLambda{p} }, func() resource.Resource { return ResourceIngressRuleMongo{p} }, func() resource.Resource { return ResourceIngressRulePostgresOutbox{p} }, } @@ -270,5 +276,15 @@ func (p *AblyProvider) Resources(context.Context) []func() resource.Resource { // DataSources - Gets the data sources this provider provides func (p *AblyProvider) DataSources(context.Context) []func() datasource.DataSource { - return []func() datasource.DataSource{} + return []func() datasource.DataSource{ + func() datasource.DataSource { return DataSourceMe{p} }, + func() datasource.DataSource { return DataSourceApp{p} }, + func() datasource.DataSource { return DataSourceApps{p} }, + func() datasource.DataSource { return DataSourceKey{p} }, + func() datasource.DataSource { return DataSourceKeys{p} }, + func() datasource.DataSource { return DataSourceNamespace{p} }, + func() datasource.DataSource { return DataSourceNamespaces{p} }, + func() datasource.DataSource { return DataSourceQueue{p} }, + func() datasource.DataSource { return DataSourceQueues{p} }, + } } diff --git a/internal/provider/resource_ably_rule_azure_moderation.go b/internal/provider/resource_ably_rule_azure_moderation.go new file mode 100644 index 0000000..20ff8c8 --- /dev/null +++ b/internal/provider/resource_ably_rule_azure_moderation.go @@ -0,0 +1,164 @@ +// Package provider implements the Ably provider for Terraform +package provider + +import ( + "context" + "fmt" + + "github.com/ably/terraform-provider-ably/control" + "github.com/ably/terraform-provider-ably/internal/provider/codegen/resource_rule_azure_moderation" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +// azureModerationRuleType is the Control API discriminator for Azure text +// moderation rules. +const azureModerationRuleType = "azure/text-moderation" + +// AblyRuleAzureModerationTarget mirrors control.AzureTextModerationTarget. +type AblyRuleAzureModerationTarget struct { + ApiKey types.String `tfsdk:"api_key"` + Endpoint types.String `tfsdk:"endpoint"` + Thresholds types.Map `tfsdk:"thresholds"` +} + +// AblyRuleAzureModeration is the tfsdk model for the Azure text moderation +// rule. Like the other moderation rules it has no `source` and no +// `request_mode`; see before_publish_rules.go. +type AblyRuleAzureModeration struct { + ID types.String `tfsdk:"id"` + AppID types.String `tfsdk:"app_id"` + Status types.String `tfsdk:"status"` + InvocationMode types.String `tfsdk:"invocation_mode"` + ChatRoomFilter types.String `tfsdk:"chat_room_filter"` + BeforePublishConfig *AblyBeforePublishConfig `tfsdk:"before_publish_config"` + Target *AblyRuleAzureModerationTarget `tfsdk:"target"` +} + +type ResourceRuleAzureModeration struct { + p *AblyProvider +} + +var _ resource.Resource = &ResourceRuleAzureModeration{} +var _ resource.ResourceWithImportState = &ResourceRuleAzureModeration{} + +// Schema defines the schema for the resource. +// +// PORTED ONTO GENERATED CODE (see DEVELOPMENT.md "Porting a resource onto +// generated code"). +func (r ResourceRuleAzureModeration) Schema(ctx context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + s := resource_rule_azure_moderation.RuleAzureModerationResourceSchema(ctx) + stripNestedCustomTypes(&s, "before_publish_config", "target") + + s.MarkdownDescription = "The `ably_rule_azure_moderation` resource allows you to create and manage an Ably integration rule for Azure AI Content Safety text moderation. This rule moderates messages before they are published. Read more at https://ably.com/docs/chat/moderation" + + resp.Schema = s +} + +func (r ResourceRuleAzureModeration) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = "ably_rule_azure_moderation" +} + +func (r *ResourceRuleAzureModeration) Provider() *AblyProvider { + return r.p +} + +func (r *ResourceRuleAzureModeration) Name() string { + return "Azure text moderation" +} + +func (r ResourceRuleAzureModeration) crud() beforePublishCRUD[AblyRuleAzureModeration] { + return beforePublishCRUD[AblyRuleAzureModeration]{ + p: r.p, + name: r.Name(), + appID: func(m AblyRuleAzureModeration) string { return m.AppID.ValueString() }, + id: func(m AblyRuleAzureModeration) string { return m.ID.ValueString() }, + post: getPlanAzureModerationPost, + response: getAzureModerationResponse, + } +} + +// getPlanAzureModerationPost converts the plan model into the Control API +// create body. +func getPlanAzureModerationPost(ctx context.Context, plan AblyRuleAzureModeration) (any, diag.Diagnostics) { + thresholds, diags := thresholdsPost(ctx, plan.Target.Thresholds) + if diags.HasError() { + return nil, diags + } + + return control.AzureTextModerationRulePost{ + Status: plan.Status.ValueString(), + RuleType: azureModerationRuleType, + InvocationMode: plan.InvocationMode.ValueString(), + ChatRoomFilter: plan.ChatRoomFilter.ValueString(), + BeforePublishConfig: beforePublishConfigPost(plan.BeforePublishConfig), + Target: control.AzureTextModerationTarget{ + APIKey: plan.Target.ApiKey.ValueString(), + Endpoint: plan.Target.Endpoint.ValueString(), + Thresholds: thresholds, + }, + }, diags +} + +// getAzureModerationResponse maps an API rule response back onto the tfsdk +// model, api_key included: the Control API returns the full target for +// moderation rules, so out-of-band changes surface as drift and import captures +// the complete resource. +func getAzureModerationResponse(ctx context.Context, rule *control.RuleResponse, _ *AblyRuleAzureModeration) (AblyRuleAzureModeration, diag.Diagnostics) { + diags := checkRuleType(rule, azureModerationRuleType) + if diags.HasError() { + return AblyRuleAzureModeration{}, diags + } + + target, err := unmarshalTarget[control.AzureTextModerationTarget](rule.Target) + if err != nil { + diags.AddError("Error unmarshalling rule target", fmt.Sprintf("Could not unmarshal %s target: %s", azureModerationRuleType, err.Error())) + return AblyRuleAzureModeration{}, diags + } + + thresholds, thresholdDiags := thresholdsResponse(ctx, target.Thresholds) + diags.Append(thresholdDiags...) + if diags.HasError() { + return AblyRuleAzureModeration{}, diags + } + + return AblyRuleAzureModeration{ + ID: types.StringValue(rule.ID), + AppID: types.StringValue(rule.AppID), + Status: types.StringValue(rule.Status), + InvocationMode: stringOrNull(rule.InvocationMode), + ChatRoomFilter: stringOrNull(rule.ChatRoomFilter), + BeforePublishConfig: beforePublishConfigResponse(rule.BeforePublishConfig), + Target: &AblyRuleAzureModerationTarget{ + ApiKey: stringOrNull(target.APIKey), + Endpoint: stringOrNull(target.Endpoint), + Thresholds: thresholds, + }, + }, diags +} + +// Create creates a new resource. +func (r ResourceRuleAzureModeration) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + r.crud().create(ctx, req, resp) +} + +// Read reads the resource. +func (r ResourceRuleAzureModeration) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + r.crud().read(ctx, req, resp) +} + +// Update updates an existing resource. +func (r ResourceRuleAzureModeration) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + r.crud().update(ctx, req, resp) +} + +// Delete deletes the resource. +func (r ResourceRuleAzureModeration) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + r.crud().delete(ctx, req, resp) +} + +// ImportState handles the import state functionality. +func (r ResourceRuleAzureModeration) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + ImportResource(ctx, req, resp, "app_id", "id") +} diff --git a/internal/provider/resource_ably_rule_azure_moderation_test.go b/internal/provider/resource_ably_rule_azure_moderation_test.go new file mode 100644 index 0000000..504c8ff --- /dev/null +++ b/internal/provider/resource_ably_rule_azure_moderation_test.go @@ -0,0 +1,97 @@ +// Package provider implements the Ably provider for Terraform +package provider + +import ( + "fmt" + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/acctest" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" +) + +func TestAccAblyRuleAzureModeration(t *testing.T) { + appName := acctest.RandStringFromCharSet(15, acctest.CharSetAlphaNum) + updateAppName := "acc-test-" + appName + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + // Create and Read testing + { + Config: testAccAblyRuleAzureModerationConfig(appName, "/room-.*/", "https://my-resource.cognitiveservices.azure.com", 2), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("ably_app.app0", "name", appName), + resource.TestCheckResourceAttr("ably_rule_azure_moderation.rule0", "status", "enabled"), + resource.TestCheckResourceAttr("ably_rule_azure_moderation.rule0", "invocation_mode", "BEFORE_PUBLISH"), + resource.TestCheckResourceAttr("ably_rule_azure_moderation.rule0", "chat_room_filter", "/room-.*/"), + resource.TestCheckResourceAttr("ably_rule_azure_moderation.rule0", "target.endpoint", "https://my-resource.cognitiveservices.azure.com"), + resource.TestCheckResourceAttr("ably_rule_azure_moderation.rule0", "target.thresholds.Hate", "2"), + ), + }, + // ImportState testing + { + ResourceName: "ably_rule_azure_moderation.rule0", + ImportState: true, + ImportStateVerify: true, + ImportStateIdFunc: importStateIDFunc("ably_rule_azure_moderation.rule0"), + }, + // Update and Read testing + { + Config: testAccAblyRuleAzureModerationConfig(updateAppName, "/chat-.*/", "https://other-resource.cognitiveservices.azure.com", 4), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("ably_app.app0", "name", updateAppName), + resource.TestCheckResourceAttr("ably_rule_azure_moderation.rule0", "chat_room_filter", "/chat-.*/"), + resource.TestCheckResourceAttr("ably_rule_azure_moderation.rule0", "target.endpoint", "https://other-resource.cognitiveservices.azure.com"), + resource.TestCheckResourceAttr("ably_rule_azure_moderation.rule0", "target.thresholds.Hate", "4"), + ), + }, + // Delete testing automatically occurs in TestCase + }, + }) +} + +// Function with inline HCL to provision an ably_app and an Azure moderation rule. +func testAccAblyRuleAzureModerationConfig( + appName string, + chatRoomFilter string, + endpoint string, + hateThreshold int, +) string { + return fmt.Sprintf(` +terraform { + required_providers { + ably = { + source = "registry.terraform.io/ably/ably" + } + } +} +provider "ably" {} + +resource "ably_app" "app0" { + name = %[1]q + status = "enabled" + tls_only = true +} + +resource "ably_rule_azure_moderation" "rule0" { + app_id = ably_app.app0.id + status = "enabled" + invocation_mode = "BEFORE_PUBLISH" + chat_room_filter = %[2]q + before_publish_config = { + retry_timeout = 5000 + max_retries = 3 + failed_action = "PUBLISH" + too_many_requests_action = "RETRY" + } + target = { + api_key = "my-azure-api-key" + endpoint = %[3]q + thresholds = { + Hate = %[4]d + } + } +} +`, appName, chatRoomFilter, endpoint, hateThreshold) +} diff --git a/internal/provider/resource_ably_rule_before_publish_lambda.go b/internal/provider/resource_ably_rule_before_publish_lambda.go new file mode 100644 index 0000000..635400d --- /dev/null +++ b/internal/provider/resource_ably_rule_before_publish_lambda.go @@ -0,0 +1,289 @@ +// Package provider implements the Ably provider for Terraform +package provider + +import ( + "context" + "fmt" + + "github.com/ably/terraform-provider-ably/control" + "github.com/ably/terraform-provider-ably/internal/provider/codegen/resource_rule_before_publish_lambda" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" +) + +// beforePublishLambdaRuleType is the Control API discriminator for +// before-publish AWS Lambda rules. +const beforePublishLambdaRuleType = "aws/lambda/before-publish" + +// AblyRuleBeforePublishLambdaAuth mirrors control.AWSAuthentication. +// +// Note the attribute names differ from the older hand-written AWS rules +// (ably_rule_lambda et al), which use `mode` and `role_arn`. These come from the +// generated schema, which follows the Control API spec +// (`authentication_mode`, `assume_role_arn`). +type AblyRuleBeforePublishLambdaAuth struct { + AuthenticationMode types.String `tfsdk:"authentication_mode"` + AccessKeyID types.String `tfsdk:"access_key_id"` + SecretAccessKey types.String `tfsdk:"secret_access_key"` + AssumeRoleArn types.String `tfsdk:"assume_role_arn"` +} + +// AblyRuleBeforePublishLambdaTarget mirrors +// control.BeforePublishAWSLambdaTarget. +type AblyRuleBeforePublishLambdaTarget struct { + Region types.String `tfsdk:"region"` + FunctionName types.String `tfsdk:"function_name"` + Authentication *AblyRuleBeforePublishLambdaAuth `tfsdk:"authentication"` +} + +// AblyRuleBeforePublishLambda is the tfsdk model for the before-publish AWS +// Lambda rule. +// +// This is the one before-publish family that takes a `source`, and it is +// optional here rather than required as it is for webhook/firehose rules. It +// still has no `request_mode`, so it does not use the AblyRule plumbing; see +// before_publish_rules.go. +type AblyRuleBeforePublishLambda struct { + ID types.String `tfsdk:"id"` + AppID types.String `tfsdk:"app_id"` + Status types.String `tfsdk:"status"` + InvocationMode types.String `tfsdk:"invocation_mode"` + ChatRoomFilter types.String `tfsdk:"chat_room_filter"` + BeforePublishConfig *AblyBeforePublishConfig `tfsdk:"before_publish_config"` + // Source is types.Object rather than a plain struct because the API defaults + // it: the schema is Optional AND Computed, so an omitted block reaches the + // provider as unknown, which a *AblyRuleSource cannot represent. + Source types.Object `tfsdk:"source"` + Target *AblyRuleBeforePublishLambdaTarget `tfsdk:"target"` +} + +type ResourceRuleBeforePublishLambda struct { + p *AblyProvider +} + +var _ resource.Resource = &ResourceRuleBeforePublishLambda{} +var _ resource.ResourceWithImportState = &ResourceRuleBeforePublishLambda{} + +// Schema defines the schema for the resource. +// +// PORTED ONTO GENERATED CODE (see DEVELOPMENT.md "Porting a resource onto +// generated code"). +func (r ResourceRuleBeforePublishLambda) Schema(ctx context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + s := resource_rule_before_publish_lambda.RuleBeforePublishLambdaResourceSchema(ctx) + stripNestedCustomTypes(&s, "before_publish_config", "source", "target") + stripSingleNestedCustomType(&s, "target", "authentication") + + s.MarkdownDescription = "The `ably_rule_before_publish_lambda` resource allows you to create and manage an Ably integration rule that invokes an AWS Lambda function before a message is published, so the function can allow, reject or amend it. Read more at https://ably.com/docs/chat/moderation" + + resp.Schema = s +} + +func (r ResourceRuleBeforePublishLambda) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = "ably_rule_before_publish_lambda" +} + +func (r *ResourceRuleBeforePublishLambda) Provider() *AblyProvider { + return r.p +} + +func (r *ResourceRuleBeforePublishLambda) Name() string { + return "Before-publish AWS Lambda" +} + +func (r ResourceRuleBeforePublishLambda) crud() beforePublishCRUD[AblyRuleBeforePublishLambda] { + return beforePublishCRUD[AblyRuleBeforePublishLambda]{ + p: r.p, + name: r.Name(), + appID: func(m AblyRuleBeforePublishLambda) string { return m.AppID.ValueString() }, + id: func(m AblyRuleBeforePublishLambda) string { return m.ID.ValueString() }, + post: getPlanBeforePublishLambdaPost, + response: getBeforePublishLambdaResponse, + } +} + +// beforePublishLambdaAuthPost converts the plan's authentication block into the +// control type. Only the fields the chosen mode uses are sent: the API rejects +// credentials alongside an assume-role ARN and vice versa. This mirrors +// GetPlanAwsAuth for the webhook/firehose AWS rules. +func beforePublishLambdaAuthPost(auth *AblyRuleBeforePublishLambdaAuth) control.AWSAuthentication { + if auth == nil { + return control.AWSAuthentication{} + } + + switch control.AWSAuthMode(auth.AuthenticationMode.ValueString()) { + case control.AWSAuthModeAssumeRole: + return control.AWSAuthentication{ + AuthenticationMode: string(control.AWSAuthModeAssumeRole), + AssumeRoleArn: auth.AssumeRoleArn.ValueString(), + } + case control.AWSAuthModeCredentials: + return control.AWSAuthentication{ + AuthenticationMode: string(control.AWSAuthModeCredentials), + AccessKeyID: auth.AccessKeyID.ValueString(), + SecretAccessKey: auth.SecretAccessKey.ValueString(), + } + } + + return control.AWSAuthentication{} +} + +// AblyChatMessageSource is the tfsdk model for a before-publish rule's source. +// It carries only a type: the API's chat source schema has no channel filter (see +// control.ChatMessageRuleSource). +type AblyChatMessageSource struct { + Type types.String `tfsdk:"type"` +} + +// beforePublishLambdaSourceAttrTypes is the attribute type map of the source +// block, needed to build its object value. +var beforePublishLambdaSourceAttrTypes = map[string]attr.Type{ + "type": types.StringType, +} + +// getPlanBeforePublishLambdaPost converts the plan model into the Control API +// create body. +func getPlanBeforePublishLambdaPost(ctx context.Context, plan AblyRuleBeforePublishLambda) (any, diag.Diagnostics) { + var diags diag.Diagnostics + + // An omitted source is unknown, not null, because the API assigns a default + // one. Either way we send nothing and let the API decide. + var source *control.ChatMessageRuleSource + if !plan.Source.IsNull() && !plan.Source.IsUnknown() { + var planSource AblyChatMessageSource + diags.Append(plan.Source.As(ctx, &planSource, basetypes.ObjectAsOptions{})...) + if diags.HasError() { + return nil, diags + } + source = &control.ChatMessageRuleSource{Type: planSource.Type.ValueString()} + } + + return control.BeforePublishAWSLambdaRulePost{ + Status: plan.Status.ValueString(), + RuleType: beforePublishLambdaRuleType, + InvocationMode: plan.InvocationMode.ValueString(), + ChatRoomFilter: plan.ChatRoomFilter.ValueString(), + BeforePublishConfig: beforePublishConfigPost(plan.BeforePublishConfig), + Source: source, + Target: control.BeforePublishAWSLambdaTarget{ + Region: plan.Target.Region.ValueString(), + FunctionName: plan.Target.FunctionName.ValueString(), + Authentication: beforePublishLambdaAuthPost(plan.Target.Authentication), + }, + }, diags +} + +// beforePublishLambdaAuthResponse maps the API's authentication object back onto +// the tfsdk model. +// +// The Control API never returns secretAccessKey, so it has to be preserved from +// the plan (create/update) or prior state (read); reading it back as null would +// abort the apply with "inconsistent result after apply" on a sensitive +// attribute. The unused fields for the mode in force are explicitly null so a +// mode switch does not leave stale values in state. +func beforePublishLambdaAuthResponse(auth control.AWSAuthentication, prior *AblyRuleBeforePublishLambdaAuth) *AblyRuleBeforePublishLambdaAuth { + priorSecret := types.StringNull() + if prior != nil { + priorSecret = prior.SecretAccessKey + } + + switch control.AWSAuthMode(auth.AuthenticationMode) { + case control.AWSAuthModeCredentials: + return &AblyRuleBeforePublishLambdaAuth{ + AuthenticationMode: types.StringValue(auth.AuthenticationMode), + AccessKeyID: stringOrNull(auth.AccessKeyID), + SecretAccessKey: priorSecret, + AssumeRoleArn: types.StringNull(), + } + case control.AWSAuthModeAssumeRole: + return &AblyRuleBeforePublishLambdaAuth{ + AuthenticationMode: types.StringValue(auth.AuthenticationMode), + AccessKeyID: types.StringNull(), + SecretAccessKey: types.StringNull(), + AssumeRoleArn: stringOrNull(auth.AssumeRoleArn), + } + } + + return &AblyRuleBeforePublishLambdaAuth{ + AuthenticationMode: stringOrNull(auth.AuthenticationMode), + AccessKeyID: stringOrNull(auth.AccessKeyID), + SecretAccessKey: priorSecret, + AssumeRoleArn: stringOrNull(auth.AssumeRoleArn), + } +} + +// getBeforePublishLambdaResponse maps an API rule response back onto the tfsdk +// model, using the plan or prior state for the write-only secret_access_key. +func getBeforePublishLambdaResponse(ctx context.Context, rule *control.RuleResponse, prior *AblyRuleBeforePublishLambda) (AblyRuleBeforePublishLambda, diag.Diagnostics) { + diags := checkRuleType(rule, beforePublishLambdaRuleType) + if diags.HasError() { + return AblyRuleBeforePublishLambda{}, diags + } + + target, err := unmarshalTarget[control.BeforePublishAWSLambdaTarget](rule.Target) + if err != nil { + diags.AddError("Error unmarshalling rule target", fmt.Sprintf("Could not unmarshal %s target: %s", beforePublishLambdaRuleType, err.Error())) + return AblyRuleBeforePublishLambda{}, diags + } + + var priorAuth *AblyRuleBeforePublishLambdaAuth + if prior != nil && prior.Target != nil { + priorAuth = prior.Target.Authentication + } + + // The API returns a source whether or not one was sent, so this is normally + // the value it assigned. It stays null only where the API omits it. + source := types.ObjectNull(beforePublishLambdaSourceAttrTypes) + if rule.Source != nil { + var sourceDiags diag.Diagnostics + source, sourceDiags = types.ObjectValueFrom(ctx, beforePublishLambdaSourceAttrTypes, AblyChatMessageSource{ + Type: types.StringValue(rule.Source.Type), + }) + diags.Append(sourceDiags...) + if diags.HasError() { + return AblyRuleBeforePublishLambda{}, diags + } + } + + return AblyRuleBeforePublishLambda{ + ID: types.StringValue(rule.ID), + AppID: types.StringValue(rule.AppID), + Status: types.StringValue(rule.Status), + InvocationMode: stringOrNull(rule.InvocationMode), + ChatRoomFilter: stringOrNull(rule.ChatRoomFilter), + BeforePublishConfig: beforePublishConfigResponse(rule.BeforePublishConfig), + Source: source, + Target: &AblyRuleBeforePublishLambdaTarget{ + Region: stringOrNull(target.Region), + FunctionName: stringOrNull(target.FunctionName), + Authentication: beforePublishLambdaAuthResponse(target.Authentication, priorAuth), + }, + }, diags +} + +// Create creates a new resource. +func (r ResourceRuleBeforePublishLambda) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + r.crud().create(ctx, req, resp) +} + +// Read reads the resource. +func (r ResourceRuleBeforePublishLambda) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + r.crud().read(ctx, req, resp) +} + +// Update updates an existing resource. +func (r ResourceRuleBeforePublishLambda) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + r.crud().update(ctx, req, resp) +} + +// Delete deletes the resource. +func (r ResourceRuleBeforePublishLambda) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + r.crud().delete(ctx, req, resp) +} + +// ImportState handles the import state functionality. +func (r ResourceRuleBeforePublishLambda) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + ImportResource(ctx, req, resp, "app_id", "id") +} diff --git a/internal/provider/resource_ably_rule_before_publish_lambda_test.go b/internal/provider/resource_ably_rule_before_publish_lambda_test.go new file mode 100644 index 0000000..8032e7c --- /dev/null +++ b/internal/provider/resource_ably_rule_before_publish_lambda_test.go @@ -0,0 +1,224 @@ +// Package provider implements the Ably provider for Terraform +package provider + +import ( + "fmt" + "regexp" + "strings" + "testing" + + "github.com/ably/terraform-provider-ably/control" + + "github.com/hashicorp/terraform-plugin-testing/helper/acctest" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" +) + +func TestAccAblyRuleBeforePublishLambda(t *testing.T) { + skipIfRuleTypeUnavailable(t, beforePublishLambdaRuleType, control.BeforePublishAWSLambdaRulePost{ + RuleType: beforePublishLambdaRuleType, + InvocationMode: "BEFORE_PUBLISH", + BeforePublishConfig: beforePublishProbeConfig(), + Target: control.BeforePublishAWSLambdaTarget{ + Region: "us-west-1", + FunctionName: "my-moderation-function", + Authentication: control.AWSAuthentication{ + AuthenticationMode: string(control.AWSAuthModeAssumeRole), + AssumeRoleArn: "arn:aws:iam::123456789012:role/ably-moderation", + }, + }, + }) + + appName := acctest.RandStringFromCharSet(15, acctest.CharSetAlphaNum) + updateAppName := "acc-test-" + appName + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + // Create and Read testing + { + Config: testAccAblyRuleBeforePublishLambdaConfig(appName, "us-west-1", "my-moderation-function"), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("ably_app.app0", "name", appName), + resource.TestCheckResourceAttr("ably_rule_before_publish_lambda.rule0", "status", "enabled"), + resource.TestCheckResourceAttr("ably_rule_before_publish_lambda.rule0", "invocation_mode", "BEFORE_PUBLISH"), + resource.TestCheckResourceAttr("ably_rule_before_publish_lambda.rule0", "source.type", "chat.message"), + resource.TestCheckResourceAttr("ably_rule_before_publish_lambda.rule0", "target.region", "us-west-1"), + resource.TestCheckResourceAttr("ably_rule_before_publish_lambda.rule0", "target.function_name", "my-moderation-function"), + resource.TestCheckResourceAttr("ably_rule_before_publish_lambda.rule0", "target.authentication.authentication_mode", "credentials"), + // secret_access_key is write-only on the Control API, so it + // has to survive in state from the plan. + resource.TestCheckResourceAttr("ably_rule_before_publish_lambda.rule0", "target.authentication.secret_access_key", "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"), + ), + }, + // ImportState testing. secret_access_key cannot be imported (the API + // never returns it), so it is skipped in the verify. + { + ResourceName: "ably_rule_before_publish_lambda.rule0", + ImportState: true, + ImportStateVerify: true, + ImportStateVerifyIgnore: []string{"target.authentication.secret_access_key"}, + ImportStateIdFunc: importStateIDFunc("ably_rule_before_publish_lambda.rule0"), + }, + // Update and Read testing + { + Config: testAccAblyRuleBeforePublishLambdaConfig(updateAppName, "eu-west-2", "my-other-function"), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("ably_app.app0", "name", updateAppName), + resource.TestCheckResourceAttr("ably_rule_before_publish_lambda.rule0", "target.region", "eu-west-2"), + resource.TestCheckResourceAttr("ably_rule_before_publish_lambda.rule0", "target.function_name", "my-other-function"), + ), + }, + // Delete testing automatically occurs in TestCase + }, + }) +} + +// TestAccAblyRuleBeforePublishLambdaAssumeRole covers the other AWS auth mode: +// assumeRole sends only the ARN, and the credentials attributes must be absent +// from state rather than empty strings. +func TestAccAblyRuleBeforePublishLambdaAssumeRole(t *testing.T) { + skipIfRuleTypeUnavailable(t, beforePublishLambdaRuleType, control.BeforePublishAWSLambdaRulePost{ + RuleType: beforePublishLambdaRuleType, + InvocationMode: "BEFORE_PUBLISH", + BeforePublishConfig: beforePublishProbeConfig(), + Target: control.BeforePublishAWSLambdaTarget{ + Region: "us-west-1", + FunctionName: "my-moderation-function", + Authentication: control.AWSAuthentication{ + AuthenticationMode: string(control.AWSAuthModeAssumeRole), + AssumeRoleArn: "arn:aws:iam::123456789012:role/ably-moderation", + }, + }, + }) + + appName := acctest.RandStringFromCharSet(15, acctest.CharSetAlphaNum) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: testAccAblyRuleBeforePublishLambdaAssumeRoleConfig(appName), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("ably_rule_before_publish_lambda.rule0", "target.authentication.authentication_mode", "assumeRole"), + resource.TestCheckResourceAttr("ably_rule_before_publish_lambda.rule0", "target.authentication.assume_role_arn", "arn:aws:iam::123456789012:role/ably-moderation"), + resource.TestCheckNoResourceAttr("ably_rule_before_publish_lambda.rule0", "target.authentication.access_key_id"), + resource.TestCheckNoResourceAttr("ably_rule_before_publish_lambda.rule0", "target.authentication.secret_access_key"), + ), + }, + }, + }) +} + +// TestAccAblyRuleBeforePublishLambdaRejectsWebhookSource pins the source type at +// plan time. This rule shares its spec schema with the webhook/firehose source, +// whose documented values are channel.message and friends, but the API validates +// it against an undocumented chat-specific enum and rejects channel.message +// outright. The hermetic fake echoes whatever it is sent, so nothing but a +// plan-time validator catches this before a real apply does. +func TestAccAblyRuleBeforePublishLambdaRejectsWebhookSource(t *testing.T) { + appName := acctest.RandStringFromCharSet(15, acctest.CharSetAlphaNum) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: strings.Replace( + testAccAblyRuleBeforePublishLambdaConfig(appName, "us-west-1", "my-moderation-function"), + `type = "chat.message"`, + `type = "channel.message"`, + 1, + ), + ExpectError: regexp.MustCompile(`value must be one of: \["chat.message"\]`), + }, + }, + }) +} + +// Function with inline HCL to provision an ably_app and a before-publish Lambda +// rule authenticating with AWS credentials. +func testAccAblyRuleBeforePublishLambdaConfig(appName, region, functionName string) string { + return fmt.Sprintf(` +terraform { + required_providers { + ably = { + source = "registry.terraform.io/ably/ably" + } + } +} +provider "ably" {} + +resource "ably_app" "app0" { + name = %[1]q + status = "enabled" + tls_only = true +} + +resource "ably_rule_before_publish_lambda" "rule0" { + app_id = ably_app.app0.id + status = "enabled" + invocation_mode = "BEFORE_PUBLISH" + before_publish_config = { + retry_timeout = 5000 + max_retries = 3 + failed_action = "PUBLISH" + too_many_requests_action = "RETRY" + } + source = { + type = "chat.message" + } + target = { + region = %[2]q + function_name = %[3]q + authentication = { + authentication_mode = "credentials" + access_key_id = "AKIAIOSFODNN7EXAMPLE" + secret_access_key = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY" + } + } +} +`, appName, region, functionName) +} + +// Function with inline HCL to provision an ably_app and a before-publish Lambda +// rule authenticating with an assumable role. +func testAccAblyRuleBeforePublishLambdaAssumeRoleConfig(appName string) string { + return fmt.Sprintf(` +terraform { + required_providers { + ably = { + source = "registry.terraform.io/ably/ably" + } + } +} +provider "ably" {} + +resource "ably_app" "app0" { + name = %[1]q + status = "enabled" + tls_only = true +} + +resource "ably_rule_before_publish_lambda" "rule0" { + app_id = ably_app.app0.id + status = "enabled" + invocation_mode = "BEFORE_PUBLISH" + before_publish_config = { + retry_timeout = 5000 + max_retries = 3 + failed_action = "PUBLISH" + too_many_requests_action = "RETRY" + } + target = { + region = "us-west-1" + function_name = "my-moderation-function" + authentication = { + authentication_mode = "assumeRole" + assume_role_arn = "arn:aws:iam::123456789012:role/ably-moderation" + } + } +} +`, appName) +} diff --git a/internal/provider/resource_ably_rule_before_publish_webhook.go b/internal/provider/resource_ably_rule_before_publish_webhook.go new file mode 100644 index 0000000..8688fc4 --- /dev/null +++ b/internal/provider/resource_ably_rule_before_publish_webhook.go @@ -0,0 +1,155 @@ +// Package provider implements the Ably provider for Terraform +package provider + +import ( + "context" + "fmt" + + "github.com/ably/terraform-provider-ably/control" + "github.com/ably/terraform-provider-ably/internal/provider/codegen/resource_rule_before_publish_webhook" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +// beforePublishWebhookRuleType is the Control API discriminator for +// before-publish webhook rules. +const beforePublishWebhookRuleType = "http/before-publish" + +// AblyRuleBeforePublishWebhookTarget mirrors +// control.BeforePublishWebhookTarget. +type AblyRuleBeforePublishWebhookTarget struct { + Url types.String `tfsdk:"url"` + Headers []AblyRuleHeaders `tfsdk:"headers"` +} + +// AblyRuleBeforePublishWebhook is the tfsdk model for the before-publish +// webhook rule. +// +// Despite being a webhook this is NOT the AblyRule shape: before-publish rules +// have no `source` and no `request_mode`, and carry `invocation_mode`, +// `chat_room_filter` and `before_publish_config` instead. See +// before_publish_rules.go. +type AblyRuleBeforePublishWebhook struct { + ID types.String `tfsdk:"id"` + AppID types.String `tfsdk:"app_id"` + Status types.String `tfsdk:"status"` + InvocationMode types.String `tfsdk:"invocation_mode"` + ChatRoomFilter types.String `tfsdk:"chat_room_filter"` + BeforePublishConfig *AblyBeforePublishConfig `tfsdk:"before_publish_config"` + Target *AblyRuleBeforePublishWebhookTarget `tfsdk:"target"` +} + +type ResourceRuleBeforePublishWebhook struct { + p *AblyProvider +} + +var _ resource.Resource = &ResourceRuleBeforePublishWebhook{} +var _ resource.ResourceWithImportState = &ResourceRuleBeforePublishWebhook{} + +// Schema defines the schema for the resource. +// +// PORTED ONTO GENERATED CODE (see DEVELOPMENT.md "Porting a resource onto +// generated code"). +func (r ResourceRuleBeforePublishWebhook) Schema(ctx context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + s := resource_rule_before_publish_webhook.RuleBeforePublishWebhookResourceSchema(ctx) + stripNestedCustomTypes(&s, "before_publish_config", "target") + stripListNestedCustomType(&s, "target", "headers") + + s.MarkdownDescription = "The `ably_rule_before_publish_webhook` resource allows you to create and manage an Ably integration rule that calls your own HTTP endpoint before a message is published, so the endpoint can allow, reject or amend it. Read more at https://ably.com/docs/chat/moderation" + + resp.Schema = s +} + +func (r ResourceRuleBeforePublishWebhook) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = "ably_rule_before_publish_webhook" +} + +func (r *ResourceRuleBeforePublishWebhook) Provider() *AblyProvider { + return r.p +} + +func (r *ResourceRuleBeforePublishWebhook) Name() string { + return "Before-publish webhook" +} + +func (r ResourceRuleBeforePublishWebhook) crud() beforePublishCRUD[AblyRuleBeforePublishWebhook] { + return beforePublishCRUD[AblyRuleBeforePublishWebhook]{ + p: r.p, + name: r.Name(), + appID: func(m AblyRuleBeforePublishWebhook) string { return m.AppID.ValueString() }, + id: func(m AblyRuleBeforePublishWebhook) string { return m.ID.ValueString() }, + post: getPlanBeforePublishWebhookPost, + response: getBeforePublishWebhookResponse, + } +} + +// getPlanBeforePublishWebhookPost converts the plan model into the Control API +// create body. +func getPlanBeforePublishWebhookPost(_ context.Context, plan AblyRuleBeforePublishWebhook) (any, diag.Diagnostics) { + return control.BeforePublishWebhookRulePost{ + Status: plan.Status.ValueString(), + RuleType: beforePublishWebhookRuleType, + InvocationMode: plan.InvocationMode.ValueString(), + ChatRoomFilter: plan.ChatRoomFilter.ValueString(), + BeforePublishConfig: beforePublishConfigPost(plan.BeforePublishConfig), + Target: control.BeforePublishWebhookTarget{ + URL: plan.Target.Url.ValueString(), + Headers: GetHeaders(plan.Target.Headers), + }, + }, nil +} + +// getBeforePublishWebhookResponse maps an API rule response back onto the tfsdk +// model. Headers are returned by the API, so they come from the response rather +// than the plan. +func getBeforePublishWebhookResponse(_ context.Context, rule *control.RuleResponse, _ *AblyRuleBeforePublishWebhook) (AblyRuleBeforePublishWebhook, diag.Diagnostics) { + diags := checkRuleType(rule, beforePublishWebhookRuleType) + if diags.HasError() { + return AblyRuleBeforePublishWebhook{}, diags + } + + target, err := unmarshalTarget[control.BeforePublishWebhookTarget](rule.Target) + if err != nil { + diags.AddError("Error unmarshalling rule target", fmt.Sprintf("Could not unmarshal %s target: %s", beforePublishWebhookRuleType, err.Error())) + return AblyRuleBeforePublishWebhook{}, diags + } + + return AblyRuleBeforePublishWebhook{ + ID: types.StringValue(rule.ID), + AppID: types.StringValue(rule.AppID), + Status: types.StringValue(rule.Status), + InvocationMode: stringOrNull(rule.InvocationMode), + ChatRoomFilter: stringOrNull(rule.ChatRoomFilter), + BeforePublishConfig: beforePublishConfigResponse(rule.BeforePublishConfig), + Target: &AblyRuleBeforePublishWebhookTarget{ + Url: stringOrNull(target.URL), + Headers: ToHeaders(target.Headers), + }, + }, diags +} + +// Create creates a new resource. +func (r ResourceRuleBeforePublishWebhook) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + r.crud().create(ctx, req, resp) +} + +// Read reads the resource. +func (r ResourceRuleBeforePublishWebhook) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + r.crud().read(ctx, req, resp) +} + +// Update updates an existing resource. +func (r ResourceRuleBeforePublishWebhook) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + r.crud().update(ctx, req, resp) +} + +// Delete deletes the resource. +func (r ResourceRuleBeforePublishWebhook) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + r.crud().delete(ctx, req, resp) +} + +// ImportState handles the import state functionality. +func (r ResourceRuleBeforePublishWebhook) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + ImportResource(ctx, req, resp, "app_id", "id") +} diff --git a/internal/provider/resource_ably_rule_before_publish_webhook_test.go b/internal/provider/resource_ably_rule_before_publish_webhook_test.go new file mode 100644 index 0000000..8975888 --- /dev/null +++ b/internal/provider/resource_ably_rule_before_publish_webhook_test.go @@ -0,0 +1,111 @@ +// Package provider implements the Ably provider for Terraform +package provider + +import ( + "fmt" + "testing" + + "github.com/ably/terraform-provider-ably/control" + + "github.com/hashicorp/terraform-plugin-testing/helper/acctest" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" +) + +func TestAccAblyRuleBeforePublishWebhook(t *testing.T) { + skipIfRuleTypeUnavailable(t, beforePublishWebhookRuleType, control.BeforePublishWebhookRulePost{ + RuleType: beforePublishWebhookRuleType, + InvocationMode: "BEFORE_PUBLISH", + BeforePublishConfig: beforePublishProbeConfig(), + Target: control.BeforePublishWebhookTarget{ + URL: "https://example.com/moderate", + }, + }) + + appName := acctest.RandStringFromCharSet(15, acctest.CharSetAlphaNum) + updateAppName := "acc-test-" + appName + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + // Create and Read testing + { + Config: testAccAblyRuleBeforePublishWebhookConfig(appName, "/room-.*/", "https://example.com/moderate", "custom-header-value"), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("ably_app.app0", "name", appName), + resource.TestCheckResourceAttr("ably_rule_before_publish_webhook.rule0", "status", "enabled"), + resource.TestCheckResourceAttr("ably_rule_before_publish_webhook.rule0", "invocation_mode", "BEFORE_PUBLISH"), + resource.TestCheckResourceAttr("ably_rule_before_publish_webhook.rule0", "chat_room_filter", "/room-.*/"), + resource.TestCheckResourceAttr("ably_rule_before_publish_webhook.rule0", "target.url", "https://example.com/moderate"), + resource.TestCheckResourceAttr("ably_rule_before_publish_webhook.rule0", "target.headers.0.name", "X-Custom-Header"), + resource.TestCheckResourceAttr("ably_rule_before_publish_webhook.rule0", "target.headers.0.value", "custom-header-value"), + // Before-publish rules are not webhook-shaped: no source, no + // request_mode. + resource.TestCheckNoResourceAttr("ably_rule_before_publish_webhook.rule0", "source"), + resource.TestCheckNoResourceAttr("ably_rule_before_publish_webhook.rule0", "request_mode"), + ), + }, + // ImportState testing + { + ResourceName: "ably_rule_before_publish_webhook.rule0", + ImportState: true, + ImportStateVerify: true, + ImportStateIdFunc: importStateIDFunc("ably_rule_before_publish_webhook.rule0"), + }, + // Update and Read testing + { + Config: testAccAblyRuleBeforePublishWebhookConfig(updateAppName, "/chat-.*/", "https://example.com/moderate-v2", "updated-header-value"), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("ably_app.app0", "name", updateAppName), + resource.TestCheckResourceAttr("ably_rule_before_publish_webhook.rule0", "chat_room_filter", "/chat-.*/"), + resource.TestCheckResourceAttr("ably_rule_before_publish_webhook.rule0", "target.url", "https://example.com/moderate-v2"), + resource.TestCheckResourceAttr("ably_rule_before_publish_webhook.rule0", "target.headers.0.value", "updated-header-value"), + ), + }, + // Delete testing automatically occurs in TestCase + }, + }) +} + +// Function with inline HCL to provision an ably_app and a before-publish webhook +// rule. +func testAccAblyRuleBeforePublishWebhookConfig(appName, chatRoomFilter, url, headerValue string) string { + return fmt.Sprintf(` +terraform { + required_providers { + ably = { + source = "registry.terraform.io/ably/ably" + } + } +} +provider "ably" {} + +resource "ably_app" "app0" { + name = %[1]q + status = "enabled" + tls_only = true +} + +resource "ably_rule_before_publish_webhook" "rule0" { + app_id = ably_app.app0.id + status = "enabled" + invocation_mode = "BEFORE_PUBLISH" + chat_room_filter = %[2]q + before_publish_config = { + retry_timeout = 5000 + max_retries = 3 + failed_action = "PUBLISH" + too_many_requests_action = "RETRY" + } + target = { + url = %[3]q + headers = [ + { + name = "X-Custom-Header" + value = %[4]q + }, + ] + } +} +`, appName, chatRoomFilter, url, headerValue) +} diff --git a/internal/provider/resource_ably_rule_hive_dashboard.go b/internal/provider/resource_ably_rule_hive_dashboard.go new file mode 100644 index 0000000..eb3f904 --- /dev/null +++ b/internal/provider/resource_ably_rule_hive_dashboard.go @@ -0,0 +1,151 @@ +// Package provider implements the Ably provider for Terraform +package provider + +import ( + "context" + "fmt" + + "github.com/ably/terraform-provider-ably/control" + "github.com/ably/terraform-provider-ably/internal/provider/codegen/resource_rule_hive_dashboard" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +// hiveDashboardRuleType is the Control API discriminator for Hive dashboard +// moderation rules. +const hiveDashboardRuleType = "hive/dashboard" + +// AblyRuleHiveDashboardTarget mirrors control.HiveDashboardTarget. +type AblyRuleHiveDashboardTarget struct { + ApiKey types.String `tfsdk:"api_key"` + CheckWatchLists types.Bool `tfsdk:"check_watch_lists"` +} + +// AblyRuleHiveDashboard is the tfsdk model for the Hive dashboard moderation +// rule. +// +// Note: unlike the other moderation rules, Hive dashboard rules run after a +// message is published (invocation_mode AFTER_PUBLISH) and carry NO +// before_publish_config; moderation decisions are made in the Hive dashboard, so +// there is no endpoint to retry against before publishing. +type AblyRuleHiveDashboard struct { + ID types.String `tfsdk:"id"` + AppID types.String `tfsdk:"app_id"` + Status types.String `tfsdk:"status"` + InvocationMode types.String `tfsdk:"invocation_mode"` + ChatRoomFilter types.String `tfsdk:"chat_room_filter"` + Target *AblyRuleHiveDashboardTarget `tfsdk:"target"` +} + +type ResourceRuleHiveDashboard struct { + p *AblyProvider +} + +var _ resource.Resource = &ResourceRuleHiveDashboard{} +var _ resource.ResourceWithImportState = &ResourceRuleHiveDashboard{} + +// Schema defines the schema for the resource. +// +// PORTED ONTO GENERATED CODE (see DEVELOPMENT.md "Porting a resource onto +// generated code"). +func (r ResourceRuleHiveDashboard) Schema(ctx context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + s := resource_rule_hive_dashboard.RuleHiveDashboardResourceSchema(ctx) + stripNestedCustomTypes(&s, "target") + + s.MarkdownDescription = "The `ably_rule_hive_dashboard` resource allows you to create and manage an Ably integration rule for Hive AI moderation with the Hive dashboard. Unlike the other moderation rules this one runs *after* a message is published (`invocation_mode` is `AFTER_PUBLISH`), so it takes no `before_publish_config`. Read more at https://ably.com/docs/chat/moderation" + + resp.Schema = s +} + +func (r ResourceRuleHiveDashboard) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = "ably_rule_hive_dashboard" +} + +func (r *ResourceRuleHiveDashboard) Provider() *AblyProvider { + return r.p +} + +func (r *ResourceRuleHiveDashboard) Name() string { + return "Hive dashboard moderation" +} + +func (r ResourceRuleHiveDashboard) crud() beforePublishCRUD[AblyRuleHiveDashboard] { + return beforePublishCRUD[AblyRuleHiveDashboard]{ + p: r.p, + name: r.Name(), + appID: func(m AblyRuleHiveDashboard) string { return m.AppID.ValueString() }, + id: func(m AblyRuleHiveDashboard) string { return m.ID.ValueString() }, + post: getPlanHiveDashboardPost, + response: getHiveDashboardResponse, + } +} + +// getPlanHiveDashboardPost converts the plan model into the Control API create +// body. +func getPlanHiveDashboardPost(_ context.Context, plan AblyRuleHiveDashboard) (any, diag.Diagnostics) { + return control.HiveDashboardRulePost{ + Status: plan.Status.ValueString(), + RuleType: hiveDashboardRuleType, + InvocationMode: plan.InvocationMode.ValueString(), + ChatRoomFilter: plan.ChatRoomFilter.ValueString(), + Target: control.HiveDashboardTarget{ + APIKey: plan.Target.ApiKey.ValueString(), + CheckWatchLists: optionalBoolPtr(plan.Target.CheckWatchLists), + }, + }, nil +} + +// getHiveDashboardResponse maps an API rule response back onto the tfsdk model, +// api_key included: the Control API returns the full target for moderation +// rules, so out-of-band changes surface as drift and import captures the +// complete resource. +func getHiveDashboardResponse(_ context.Context, rule *control.RuleResponse, _ *AblyRuleHiveDashboard) (AblyRuleHiveDashboard, diag.Diagnostics) { + diags := checkRuleType(rule, hiveDashboardRuleType) + if diags.HasError() { + return AblyRuleHiveDashboard{}, diags + } + + target, err := unmarshalTarget[control.HiveDashboardTarget](rule.Target) + if err != nil { + diags.AddError("Error unmarshalling rule target", fmt.Sprintf("Could not unmarshal %s target: %s", hiveDashboardRuleType, err.Error())) + return AblyRuleHiveDashboard{}, diags + } + + return AblyRuleHiveDashboard{ + ID: types.StringValue(rule.ID), + AppID: types.StringValue(rule.AppID), + Status: types.StringValue(rule.Status), + InvocationMode: stringOrNull(rule.InvocationMode), + ChatRoomFilter: stringOrNull(rule.ChatRoomFilter), + Target: &AblyRuleHiveDashboardTarget{ + ApiKey: stringOrNull(target.APIKey), + CheckWatchLists: optBoolValue(target.CheckWatchLists), + }, + }, diags +} + +// Create creates a new resource. +func (r ResourceRuleHiveDashboard) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + r.crud().create(ctx, req, resp) +} + +// Read reads the resource. +func (r ResourceRuleHiveDashboard) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + r.crud().read(ctx, req, resp) +} + +// Update updates an existing resource. +func (r ResourceRuleHiveDashboard) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + r.crud().update(ctx, req, resp) +} + +// Delete deletes the resource. +func (r ResourceRuleHiveDashboard) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + r.crud().delete(ctx, req, resp) +} + +// ImportState handles the import state functionality. +func (r ResourceRuleHiveDashboard) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + ImportResource(ctx, req, resp, "app_id", "id") +} diff --git a/internal/provider/resource_ably_rule_hive_dashboard_test.go b/internal/provider/resource_ably_rule_hive_dashboard_test.go new file mode 100644 index 0000000..8b37657 --- /dev/null +++ b/internal/provider/resource_ably_rule_hive_dashboard_test.go @@ -0,0 +1,84 @@ +// Package provider implements the Ably provider for Terraform +package provider + +import ( + "fmt" + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/acctest" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" +) + +func TestAccAblyRuleHiveDashboard(t *testing.T) { + appName := acctest.RandStringFromCharSet(15, acctest.CharSetAlphaNum) + updateAppName := "acc-test-" + appName + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + // Create and Read testing + { + Config: testAccAblyRuleHiveDashboardConfig(appName, "/room-.*/", "true"), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("ably_app.app0", "name", appName), + resource.TestCheckResourceAttr("ably_rule_hive_dashboard.rule0", "status", "enabled"), + resource.TestCheckResourceAttr("ably_rule_hive_dashboard.rule0", "invocation_mode", "AFTER_PUBLISH"), + resource.TestCheckResourceAttr("ably_rule_hive_dashboard.rule0", "chat_room_filter", "/room-.*/"), + resource.TestCheckResourceAttr("ably_rule_hive_dashboard.rule0", "target.check_watch_lists", "true"), + // Hive dashboard rules carry no before_publish_config; the + // schema must not have grown one from a copied family. + resource.TestCheckNoResourceAttr("ably_rule_hive_dashboard.rule0", "before_publish_config"), + ), + }, + // ImportState testing + { + ResourceName: "ably_rule_hive_dashboard.rule0", + ImportState: true, + ImportStateVerify: true, + ImportStateIdFunc: importStateIDFunc("ably_rule_hive_dashboard.rule0"), + }, + // Update and Read testing + { + Config: testAccAblyRuleHiveDashboardConfig(updateAppName, "/chat-.*/", "false"), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("ably_app.app0", "name", updateAppName), + resource.TestCheckResourceAttr("ably_rule_hive_dashboard.rule0", "chat_room_filter", "/chat-.*/"), + resource.TestCheckResourceAttr("ably_rule_hive_dashboard.rule0", "target.check_watch_lists", "false"), + ), + }, + // Delete testing automatically occurs in TestCase + }, + }) +} + +// Function with inline HCL to provision an ably_app and a Hive dashboard rule. +func testAccAblyRuleHiveDashboardConfig(appName, chatRoomFilter, checkWatchLists string) string { + return fmt.Sprintf(` +terraform { + required_providers { + ably = { + source = "registry.terraform.io/ably/ably" + } + } +} +provider "ably" {} + +resource "ably_app" "app0" { + name = %[1]q + status = "enabled" + tls_only = true +} + +resource "ably_rule_hive_dashboard" "rule0" { + app_id = ably_app.app0.id + status = "enabled" + invocation_mode = "AFTER_PUBLISH" + chat_room_filter = %[2]q + target = { + api_key = "my-hive-api-key" + check_watch_lists = %[3]s + } +} +`, appName, chatRoomFilter, checkWatchLists) +} diff --git a/internal/provider/resource_ably_rule_hive_text.go b/internal/provider/resource_ably_rule_hive_text.go new file mode 100644 index 0000000..f086eb0 --- /dev/null +++ b/internal/provider/resource_ably_rule_hive_text.go @@ -0,0 +1,163 @@ +// Package provider implements the Ably provider for Terraform +package provider + +import ( + "context" + "fmt" + + "github.com/ably/terraform-provider-ably/control" + "github.com/ably/terraform-provider-ably/internal/provider/codegen/resource_rule_hive_text" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +// hiveTextRuleType is the Control API discriminator for Hive text-model-only +// moderation rules. +const hiveTextRuleType = "hive/text-model-only" + +// AblyRuleHiveTextTarget mirrors control.HiveTextModelOnlyTarget. +type AblyRuleHiveTextTarget struct { + ApiKey types.String `tfsdk:"api_key"` + ModelURL types.String `tfsdk:"model_url"` + Thresholds types.Map `tfsdk:"thresholds"` +} + +// AblyRuleHiveText is the tfsdk model for the Hive text-model-only moderation +// rule. Like the other moderation rules it has no `source` and no +// `request_mode`; see before_publish_rules.go. +type AblyRuleHiveText struct { + ID types.String `tfsdk:"id"` + AppID types.String `tfsdk:"app_id"` + Status types.String `tfsdk:"status"` + InvocationMode types.String `tfsdk:"invocation_mode"` + ChatRoomFilter types.String `tfsdk:"chat_room_filter"` + BeforePublishConfig *AblyBeforePublishConfig `tfsdk:"before_publish_config"` + Target *AblyRuleHiveTextTarget `tfsdk:"target"` +} + +type ResourceRuleHiveText struct { + p *AblyProvider +} + +var _ resource.Resource = &ResourceRuleHiveText{} +var _ resource.ResourceWithImportState = &ResourceRuleHiveText{} + +// Schema defines the schema for the resource. +// +// PORTED ONTO GENERATED CODE (see DEVELOPMENT.md "Porting a resource onto +// generated code"). +func (r ResourceRuleHiveText) Schema(ctx context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + s := resource_rule_hive_text.RuleHiveTextResourceSchema(ctx) + stripNestedCustomTypes(&s, "before_publish_config", "target") + + s.MarkdownDescription = "The `ably_rule_hive_text` resource allows you to create and manage an Ably integration rule for Hive AI text moderation, using the model only with no dashboard. This rule moderates messages before they are published. Read more at https://ably.com/docs/chat/moderation" + + resp.Schema = s +} + +func (r ResourceRuleHiveText) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = "ably_rule_hive_text" +} + +func (r *ResourceRuleHiveText) Provider() *AblyProvider { + return r.p +} + +func (r *ResourceRuleHiveText) Name() string { + return "Hive text moderation" +} + +func (r ResourceRuleHiveText) crud() beforePublishCRUD[AblyRuleHiveText] { + return beforePublishCRUD[AblyRuleHiveText]{ + p: r.p, + name: r.Name(), + appID: func(m AblyRuleHiveText) string { return m.AppID.ValueString() }, + id: func(m AblyRuleHiveText) string { return m.ID.ValueString() }, + post: getPlanHiveTextPost, + response: getHiveTextResponse, + } +} + +// getPlanHiveTextPost converts the plan model into the Control API create body. +func getPlanHiveTextPost(ctx context.Context, plan AblyRuleHiveText) (any, diag.Diagnostics) { + thresholds, diags := thresholdsPost(ctx, plan.Target.Thresholds) + if diags.HasError() { + return nil, diags + } + + return control.HiveTextModelOnlyRulePost{ + Status: plan.Status.ValueString(), + RuleType: hiveTextRuleType, + InvocationMode: plan.InvocationMode.ValueString(), + ChatRoomFilter: plan.ChatRoomFilter.ValueString(), + BeforePublishConfig: beforePublishConfigPost(plan.BeforePublishConfig), + Target: control.HiveTextModelOnlyTarget{ + APIKey: plan.Target.ApiKey.ValueString(), + ModelURL: plan.Target.ModelURL.ValueString(), + Thresholds: thresholds, + }, + }, diags +} + +// getHiveTextResponse maps an API rule response back onto the tfsdk model, +// api_key included: the Control API returns the full target for moderation +// rules, so out-of-band changes surface as drift and import captures the +// complete resource. +func getHiveTextResponse(ctx context.Context, rule *control.RuleResponse, _ *AblyRuleHiveText) (AblyRuleHiveText, diag.Diagnostics) { + diags := checkRuleType(rule, hiveTextRuleType) + if diags.HasError() { + return AblyRuleHiveText{}, diags + } + + target, err := unmarshalTarget[control.HiveTextModelOnlyTarget](rule.Target) + if err != nil { + diags.AddError("Error unmarshalling rule target", fmt.Sprintf("Could not unmarshal %s target: %s", hiveTextRuleType, err.Error())) + return AblyRuleHiveText{}, diags + } + + thresholds, thresholdDiags := thresholdsResponse(ctx, target.Thresholds) + diags.Append(thresholdDiags...) + if diags.HasError() { + return AblyRuleHiveText{}, diags + } + + return AblyRuleHiveText{ + ID: types.StringValue(rule.ID), + AppID: types.StringValue(rule.AppID), + Status: types.StringValue(rule.Status), + InvocationMode: stringOrNull(rule.InvocationMode), + ChatRoomFilter: stringOrNull(rule.ChatRoomFilter), + BeforePublishConfig: beforePublishConfigResponse(rule.BeforePublishConfig), + Target: &AblyRuleHiveTextTarget{ + ApiKey: stringOrNull(target.APIKey), + ModelURL: stringOrNull(target.ModelURL), + Thresholds: thresholds, + }, + }, diags +} + +// Create creates a new resource. +func (r ResourceRuleHiveText) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + r.crud().create(ctx, req, resp) +} + +// Read reads the resource. +func (r ResourceRuleHiveText) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + r.crud().read(ctx, req, resp) +} + +// Update updates an existing resource. +func (r ResourceRuleHiveText) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + r.crud().update(ctx, req, resp) +} + +// Delete deletes the resource. +func (r ResourceRuleHiveText) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + r.crud().delete(ctx, req, resp) +} + +// ImportState handles the import state functionality. +func (r ResourceRuleHiveText) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + ImportResource(ctx, req, resp, "app_id", "id") +} diff --git a/internal/provider/resource_ably_rule_hive_text_test.go b/internal/provider/resource_ably_rule_hive_text_test.go new file mode 100644 index 0000000..10fb668 --- /dev/null +++ b/internal/provider/resource_ably_rule_hive_text_test.go @@ -0,0 +1,100 @@ +// Package provider implements the Ably provider for Terraform +package provider + +import ( + "fmt" + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/acctest" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" +) + +func TestAccAblyRuleHiveText(t *testing.T) { + appName := acctest.RandStringFromCharSet(15, acctest.CharSetAlphaNum) + updateAppName := "acc-test-" + appName + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + // Create and Read testing + { + Config: testAccAblyRuleHiveTextConfig(appName, "/room-.*/", `model_url = "https://api.thehive.ai/api/v2/task/sync"`, 2), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("ably_app.app0", "name", appName), + resource.TestCheckResourceAttr("ably_rule_hive_text.rule0", "status", "enabled"), + resource.TestCheckResourceAttr("ably_rule_hive_text.rule0", "invocation_mode", "BEFORE_PUBLISH"), + resource.TestCheckResourceAttr("ably_rule_hive_text.rule0", "chat_room_filter", "/room-.*/"), + resource.TestCheckResourceAttr("ably_rule_hive_text.rule0", "target.model_url", "https://api.thehive.ai/api/v2/task/sync"), + resource.TestCheckResourceAttr("ably_rule_hive_text.rule0", "target.thresholds.bullying", "2"), + ), + }, + // ImportState testing + { + ResourceName: "ably_rule_hive_text.rule0", + ImportState: true, + ImportStateVerify: true, + ImportStateIdFunc: importStateIDFunc("ably_rule_hive_text.rule0"), + }, + // Update and Read testing, dropping the optional model_url. An absent + // optional string must come back as null rather than "". + { + Config: testAccAblyRuleHiveTextConfig(updateAppName, "/chat-.*/", "", 3), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("ably_app.app0", "name", updateAppName), + resource.TestCheckResourceAttr("ably_rule_hive_text.rule0", "chat_room_filter", "/chat-.*/"), + resource.TestCheckNoResourceAttr("ably_rule_hive_text.rule0", "target.model_url"), + resource.TestCheckResourceAttr("ably_rule_hive_text.rule0", "target.thresholds.bullying", "3"), + ), + }, + // Delete testing automatically occurs in TestCase + }, + }) +} + +// Function with inline HCL to provision an ably_app and a Hive text moderation +// rule; extraTargetAttrs is spliced into the target block so the same config can +// be rendered with and without an optional attribute. +func testAccAblyRuleHiveTextConfig( + appName string, + chatRoomFilter string, + extraTargetAttrs string, + bullyingThreshold int, +) string { + return fmt.Sprintf(` +terraform { + required_providers { + ably = { + source = "registry.terraform.io/ably/ably" + } + } +} +provider "ably" {} + +resource "ably_app" "app0" { + name = %[1]q + status = "enabled" + tls_only = true +} + +resource "ably_rule_hive_text" "rule0" { + app_id = ably_app.app0.id + status = "enabled" + invocation_mode = "BEFORE_PUBLISH" + chat_room_filter = %[2]q + before_publish_config = { + retry_timeout = 5000 + max_retries = 3 + failed_action = "PUBLISH" + too_many_requests_action = "RETRY" + } + target = { + api_key = "my-hive-api-key" + %[3]s + thresholds = { + bullying = %[4]d + } + } +} +`, appName, chatRoomFilter, extraTargetAttrs, bullyingThreshold) +} diff --git a/internal/provider/resource_ably_rule_tisane.go b/internal/provider/resource_ably_rule_tisane.go new file mode 100644 index 0000000..26c528c --- /dev/null +++ b/internal/provider/resource_ably_rule_tisane.go @@ -0,0 +1,169 @@ +// Package provider implements the Ably provider for Terraform +package provider + +import ( + "context" + "fmt" + + "github.com/ably/terraform-provider-ably/control" + "github.com/ably/terraform-provider-ably/internal/provider/codegen/resource_rule_tisane" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +// tisaneRuleType is the Control API discriminator for Tisane text moderation +// rules. +const tisaneRuleType = "tisane/text-moderation" + +// AblyRuleTisaneTarget mirrors control.TisaneTextModerationTarget. +type AblyRuleTisaneTarget struct { + ApiKey types.String `tfsdk:"api_key"` + ModelURL types.String `tfsdk:"model_url"` + Thresholds types.Map `tfsdk:"thresholds"` + DefaultLanguage types.String `tfsdk:"default_language"` +} + +// AblyRuleTisane is the tfsdk model for the Tisane text moderation rule. Like +// the other moderation rules it has no `source` and no `request_mode`; see +// before_publish_rules.go. +type AblyRuleTisane struct { + ID types.String `tfsdk:"id"` + AppID types.String `tfsdk:"app_id"` + Status types.String `tfsdk:"status"` + InvocationMode types.String `tfsdk:"invocation_mode"` + ChatRoomFilter types.String `tfsdk:"chat_room_filter"` + BeforePublishConfig *AblyBeforePublishConfig `tfsdk:"before_publish_config"` + Target *AblyRuleTisaneTarget `tfsdk:"target"` +} + +type ResourceRuleTisane struct { + p *AblyProvider +} + +var _ resource.Resource = &ResourceRuleTisane{} +var _ resource.ResourceWithImportState = &ResourceRuleTisane{} + +// Schema defines the schema for the resource. +// +// PORTED ONTO GENERATED CODE (see DEVELOPMENT.md "Porting a resource onto +// generated code"): the attribute set, types, nesting, sensitivity, +// descriptions, validators, defaults and plan modifiers all come from +// internal/provider/codegen, produced by `make generate`. +func (r ResourceRuleTisane) Schema(ctx context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + s := resource_rule_tisane.RuleTisaneResourceSchema(ctx) + stripNestedCustomTypes(&s, "before_publish_config", "target") + + s.MarkdownDescription = "The `ably_rule_tisane` resource allows you to create and manage an Ably integration rule for Tisane text moderation. This rule moderates messages before they are published. Read more at https://ably.com/docs/chat/moderation" + + resp.Schema = s +} + +func (r ResourceRuleTisane) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = "ably_rule_tisane" +} + +func (r *ResourceRuleTisane) Provider() *AblyProvider { + return r.p +} + +func (r *ResourceRuleTisane) Name() string { + return "Tisane text moderation" +} + +func (r ResourceRuleTisane) crud() beforePublishCRUD[AblyRuleTisane] { + return beforePublishCRUD[AblyRuleTisane]{ + p: r.p, + name: r.Name(), + appID: func(m AblyRuleTisane) string { return m.AppID.ValueString() }, + id: func(m AblyRuleTisane) string { return m.ID.ValueString() }, + post: getPlanTisanePost, + response: getTisaneResponse, + } +} + +// getPlanTisanePost converts the plan model into the Control API create body. +func getPlanTisanePost(ctx context.Context, plan AblyRuleTisane) (any, diag.Diagnostics) { + thresholds, diags := thresholdsPost(ctx, plan.Target.Thresholds) + if diags.HasError() { + return nil, diags + } + + return control.TisaneTextModerationRulePost{ + Status: plan.Status.ValueString(), + RuleType: tisaneRuleType, + InvocationMode: plan.InvocationMode.ValueString(), + ChatRoomFilter: plan.ChatRoomFilter.ValueString(), + BeforePublishConfig: beforePublishConfigPost(plan.BeforePublishConfig), + Target: control.TisaneTextModerationTarget{ + APIKey: plan.Target.ApiKey.ValueString(), + ModelURL: plan.Target.ModelURL.ValueString(), + Thresholds: thresholds, + DefaultLanguage: plan.Target.DefaultLanguage.ValueString(), + }, + }, diags +} + +// getTisaneResponse maps an API rule response back onto the tfsdk model. Every +// field is read back from the response, the sensitive target api_key included: +// the Control API returns the full target for moderation rules (verified for +// bodyguard against the live API, 2026-07-08), so out-of-band changes surface as +// drift and import captures the complete resource. +func getTisaneResponse(ctx context.Context, rule *control.RuleResponse, _ *AblyRuleTisane) (AblyRuleTisane, diag.Diagnostics) { + diags := checkRuleType(rule, tisaneRuleType) + if diags.HasError() { + return AblyRuleTisane{}, diags + } + + target, err := unmarshalTarget[control.TisaneTextModerationTarget](rule.Target) + if err != nil { + diags.AddError("Error unmarshalling rule target", fmt.Sprintf("Could not unmarshal %s target: %s", tisaneRuleType, err.Error())) + return AblyRuleTisane{}, diags + } + + thresholds, thresholdDiags := thresholdsResponse(ctx, target.Thresholds) + diags.Append(thresholdDiags...) + if diags.HasError() { + return AblyRuleTisane{}, diags + } + + return AblyRuleTisane{ + ID: types.StringValue(rule.ID), + AppID: types.StringValue(rule.AppID), + Status: types.StringValue(rule.Status), + InvocationMode: stringOrNull(rule.InvocationMode), + ChatRoomFilter: stringOrNull(rule.ChatRoomFilter), + BeforePublishConfig: beforePublishConfigResponse(rule.BeforePublishConfig), + Target: &AblyRuleTisaneTarget{ + ApiKey: stringOrNull(target.APIKey), + ModelURL: stringOrNull(target.ModelURL), + Thresholds: thresholds, + DefaultLanguage: stringOrNull(target.DefaultLanguage), + }, + }, diags +} + +// Create creates a new resource. +func (r ResourceRuleTisane) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + r.crud().create(ctx, req, resp) +} + +// Read reads the resource. +func (r ResourceRuleTisane) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + r.crud().read(ctx, req, resp) +} + +// Update updates an existing resource. +func (r ResourceRuleTisane) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + r.crud().update(ctx, req, resp) +} + +// Delete deletes the resource. +func (r ResourceRuleTisane) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + r.crud().delete(ctx, req, resp) +} + +// ImportState handles the import state functionality. +func (r ResourceRuleTisane) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + ImportResource(ctx, req, resp, "app_id", "id") +} diff --git a/internal/provider/resource_ably_rule_tisane_test.go b/internal/provider/resource_ably_rule_tisane_test.go new file mode 100644 index 0000000..40bd394 --- /dev/null +++ b/internal/provider/resource_ably_rule_tisane_test.go @@ -0,0 +1,143 @@ +// Package provider implements the Ably provider for Terraform +package provider + +import ( + "fmt" + "regexp" + "strings" + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/acctest" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/terraform" +) + +func TestAccAblyRuleTisane(t *testing.T) { + appName := acctest.RandStringFromCharSet(15, acctest.CharSetAlphaNum) + updateAppName := "acc-test-" + appName + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + // Create and Read testing + { + Config: testAccAblyRuleTisaneConfig(appName, "/room-.*/", "RETRY", "en", 2), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("ably_app.app0", "name", appName), + resource.TestCheckResourceAttr("ably_rule_tisane.rule0", "status", "enabled"), + resource.TestCheckResourceAttr("ably_rule_tisane.rule0", "invocation_mode", "BEFORE_PUBLISH"), + resource.TestCheckResourceAttr("ably_rule_tisane.rule0", "chat_room_filter", "/room-.*/"), + resource.TestCheckResourceAttr("ably_rule_tisane.rule0", "before_publish_config.too_many_requests_action", "RETRY"), + resource.TestCheckResourceAttr("ably_rule_tisane.rule0", "target.default_language", "en"), + resource.TestCheckResourceAttr("ably_rule_tisane.rule0", "target.thresholds.abuse", "2"), + ), + }, + // ImportState testing. The API returns the full target for moderation + // rules, api_key included, so import verifies every attribute. + { + ResourceName: "ably_rule_tisane.rule0", + ImportState: true, + ImportStateVerify: true, + ImportStateIdFunc: importStateIDFunc("ably_rule_tisane.rule0"), + }, + // Update and Read testing + { + Config: testAccAblyRuleTisaneConfig(updateAppName, "/chat-.*/", "FAIL", "fr", 3), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("ably_app.app0", "name", updateAppName), + resource.TestCheckResourceAttr("ably_rule_tisane.rule0", "chat_room_filter", "/chat-.*/"), + resource.TestCheckResourceAttr("ably_rule_tisane.rule0", "before_publish_config.too_many_requests_action", "FAIL"), + resource.TestCheckResourceAttr("ably_rule_tisane.rule0", "target.default_language", "fr"), + resource.TestCheckResourceAttr("ably_rule_tisane.rule0", "target.thresholds.abuse", "3"), + ), + }, + // Delete testing automatically occurs in TestCase + }, + }) +} + +// TestAccAblyRuleTisaneEmptyThresholds pins the rejection of an explicit empty +// thresholds map. The control types tag thresholds omitempty, so `{}` is sent as +// absent, comes back absent, and reads as null, which would abort the apply with +// "inconsistent result after apply" naming an attribute the user did set. The +// generated mapvalidator.SizeAtLeast(1) turns that into a plan-time message. The +// echoing fake cannot catch this on its own, which is why it is pinned here. +func TestAccAblyRuleTisaneEmptyThresholds(t *testing.T) { + appName := acctest.RandStringFromCharSet(15, acctest.CharSetAlphaNum) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: strings.Replace( + testAccAblyRuleTisaneConfig(appName, "/room-.*/", "RETRY", "en", 2), + `thresholds = { + abuse = 2 + }`, + "thresholds = {}", + 1, + ), + ExpectError: regexp.MustCompile(`map must contain at least 1 element`), + }, + }, + }) +} + +// Function with inline HCL to provision an ably_app and a Tisane moderation rule. +func testAccAblyRuleTisaneConfig( + appName string, + chatRoomFilter string, + tooManyRequestsAction string, + defaultLanguage string, + abuseThreshold int, +) string { + return fmt.Sprintf(` +terraform { + required_providers { + ably = { + source = "registry.terraform.io/ably/ably" + } + } +} +provider "ably" {} + +resource "ably_app" "app0" { + name = %[1]q + status = "enabled" + tls_only = true +} + +resource "ably_rule_tisane" "rule0" { + app_id = ably_app.app0.id + status = "enabled" + invocation_mode = "BEFORE_PUBLISH" + chat_room_filter = %[2]q + before_publish_config = { + retry_timeout = 5000 + max_retries = 3 + failed_action = "PUBLISH" + too_many_requests_action = %[3]q + } + target = { + api_key = "my-tisane-api-key" + default_language = %[4]q + thresholds = { + abuse = %[5]d + } + } +} +`, appName, chatRoomFilter, tooManyRequestsAction, defaultLanguage, abuseThreshold) +} + +// importStateIDFunc builds the "app_id,id" import ID every rule resource takes. +func importStateIDFunc(resourceAddress string) func(*terraform.State) (string, error) { + return func(s *terraform.State) (string, error) { + rs, ok := s.RootModule().Resources[resourceAddress] + if !ok { + return "", fmt.Errorf("resource not found: %s", resourceAddress) + } + return fmt.Sprintf("%s,%s", rs.Primary.Attributes["app_id"], rs.Primary.ID), nil + } +} diff --git a/internal/provider/rule_entitlement_test.go b/internal/provider/rule_entitlement_test.go new file mode 100644 index 0000000..bc2bf66 --- /dev/null +++ b/internal/provider/rule_entitlement_test.go @@ -0,0 +1,119 @@ +// Package provider implements the Ably provider for Terraform +package provider + +import ( + "context" + "errors" + "fmt" + "os" + "testing" + + "github.com/ably/terraform-provider-ably/control" + "github.com/hashicorp/terraform-plugin-testing/helper/acctest" +) + +// Some rule types are gated on the account's package. The Control API answers a +// create for one the account isn't entitled to with 403 code 40300, "The rule +// type you selected is not available on your current package". +// +// That is not a provider bug, but it fails an acceptance test exactly like one, +// and the staging account CI runs against does not currently carry every rule +// type (http/before-publish, 2026-08-17). The alternative to handling it is a +// permanently red build, which teaches everyone to ignore the build. +// +// The remedy is in ably/website, not here. Rule-type availability is +// `account.current_package.supported_webhooks_types`, a persisted array on +// `package_histories`. Both before-publish types are already in +// `default_supported_webhooks_types` in `config/config.yml`, so newly-provisioned +// accounts get them; accounts created before they were added keep their old array +// and need the data migration (`rake data_migrations:append_new_default_supported_webhooks_types`, +// or the DX-530 pulsar task as precedent). Run that against staging and these +// tests start covering the rule type on their own. Tracked in INF-7994. +// +// So the affected tests probe first and skip with a loud message if the rule type +// isn't available. The probe is a real create against a throwaway app, so it +// starts passing by itself the day the entitlement is added: no test is silently +// disabled forever, which is the failure mode a plain t.Skip would have. +// +// Against the hermetic fake the probe succeeds (the fake stores whatever it is +// sent), so `make test` runs these tests as normal. + +// ruleTypeUnavailableCode is the Control API error code for a rule type the +// account's package does not include. +const ruleTypeUnavailableCode = 40300 + +// skipIfRuleTypeUnavailable skips the test when the account cannot create the +// given rule type. body must be a valid create body for it; anything else about +// it failing is left for the test itself to report. +func skipIfRuleTypeUnavailable(t *testing.T, ruleType string, body any) { + t.Helper() + + client, accountID, err := testControlClient() + if err != nil { + t.Fatalf("could not reach the Control API to check entitlement for %s: %s", ruleType, err) + } + + ctx := context.Background() + app, err := client.CreateApp(ctx, accountID, control.AppPost{ + Name: "acc-test-probe-" + acctest.RandStringFromCharSet(10, acctest.CharSetAlphaNum), + Status: "enabled", + TLSOnly: ptr(true), + }) + if err != nil { + t.Fatalf("could not create a probe app to check entitlement for %s: %s", ruleType, err) + } + defer func() { + if err := client.DeleteApp(ctx, app.ID); err != nil { + t.Logf("could not delete probe app %s: %s", app.ID, err) + } + }() + + rule, err := client.CreateRule(ctx, app.ID, body) + if err != nil { + var apiErr *control.Error + if errors.As(err, &apiErr) && apiErr.Code == ruleTypeUnavailableCode { + t.Skipf("the account's package does not include %s rules, so this test cannot run: %s", ruleType, apiErr.Message) + } + // Any other failure is the test's business, not the probe's: let the test + // run and report it properly. + return + } + if err := client.DeleteRule(ctx, app.ID, rule.ID); err != nil { + t.Logf("could not delete probe rule %s: %s", rule.ID, err) + } +} + +// testControlClient builds a Control API client from the environment the test +// suite is already configured with, and resolves the account ID. +func testControlClient() (*control.Client, string, error) { + token := os.Getenv("ABLY_ACCOUNT_TOKEN") + if token == "" { + return nil, "", fmt.Errorf("ABLY_ACCOUNT_TOKEN is not set") + } + + client := control.NewClient(token) + if url := os.Getenv("ABLY_URL"); url != "" { + client.BaseURL = url + } + + me, err := client.Me(context.Background()) + if err != nil { + return nil, "", fmt.Errorf("could not call /me: %w", err) + } + if me.Account == nil || me.Account.ID == "" { + return nil, "", fmt.Errorf("could not determine the account ID from /me") + } + + return client, me.Account.ID, nil +} + +// beforePublishProbeConfig is the retry/backoff block the entitlement probes +// send. Its values don't matter; it just has to be valid. +func beforePublishProbeConfig() control.BeforePublishConfig { + return control.BeforePublishConfig{ + RetryTimeout: 5000, + MaxRetries: 3, + FailedAction: "PUBLISH", + TooManyRequestsAction: "RETRY", + } +} diff --git a/internal/provider/rule_types.go b/internal/provider/rule_types.go index b125721..e9b0e35 100644 --- a/internal/provider/rule_types.go +++ b/internal/provider/rule_types.go @@ -35,6 +35,12 @@ var RuleTypeResources = map[string]string{ "http/google-cloud-function": "ably_rule_google_function", "http/ifttt": "ably_rule_ifttt", "bodyguard/text-moderation": "ably_rule_bodyguard", + "tisane/text-moderation": "ably_rule_tisane", + "azure/text-moderation": "ably_rule_azure_moderation", + "hive/text-model-only": "ably_rule_hive_text", + "hive/dashboard": "ably_rule_hive_dashboard", + "http/before-publish": "ably_rule_before_publish_webhook", + "aws/lambda/before-publish": "ably_rule_before_publish_lambda", "ingress/mongodb": "ably_ingress_rule_mongodb", "ingress-postgres-outbox": "ably_ingress_rule_postgres_outbox", } diff --git a/internal/provider/spec_coverage_test.go b/internal/provider/spec_coverage_test.go new file mode 100644 index 0000000..76a33cb --- /dev/null +++ b/internal/provider/spec_coverage_test.go @@ -0,0 +1,220 @@ +// Package provider implements the Ably provider for Terraform +package provider + +import ( + "os" + "path/filepath" + "sort" + "testing" + + "gopkg.in/yaml.v3" +) + +// The tests in this file answer one question: does the provider still cover the +// Control API? They compare the API's own OpenAPI spec against what the provider +// implements, and fail with the list of what is missing. +// +// They read the vendored spec by default, so they are hermetic and run in the +// normal `make test` loop. Point ABLY_SPEC_PATH at a freshly-fetched upstream +// spec to ask the same question of the API as it is today, which is what the +// scheduled spec-drift workflow does. That is the difference between "we vendored +// new surface and haven't implemented it" and "the API grew and we haven't +// noticed". + +// specPathEnv overrides the spec these tests read. +const specPathEnv = "ABLY_SPEC_PATH" + +// loadSpec reads the OpenAPI spec under test. +func loadSpec(t *testing.T) map[string]any { + t.Helper() + + path := os.Getenv(specPathEnv) + if path == "" { + path = filepath.Join("..", "..", "codegen", "control-api.yaml") + } + + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("could not read the Control API spec at %s: %s", path, err) + } + + var doc map[string]any + if err := yaml.Unmarshal(data, &doc); err != nil { + t.Fatalf("could not parse the Control API spec at %s: %s", path, err) + } + return doc +} + +// TestSpecRuleTypeCoverage fails when the spec knows about a rule type the +// provider does not, or when the provider claims one the spec has never heard +// of. The spec's rule_post schema is a oneOf with a ruleType discriminator, and +// its mapping is the authoritative list of rule types. +func TestSpecRuleTypeCoverage(t *testing.T) { + t.Parallel() + + specTypes := specRuleTypes(t) + if len(specTypes) == 0 { + t.Fatal("found no rule types in the spec; the rule_post discriminator has moved and this test needs updating") + } + + var missing, unknown []string + for _, ruleType := range specTypes { + if _, ok := RuleTypeResources[ruleType]; !ok { + missing = append(missing, ruleType) + } + } + inSpec := make(map[string]bool, len(specTypes)) + for _, ruleType := range specTypes { + inSpec[ruleType] = true + } + for ruleType := range RuleTypeResources { + if !inSpec[ruleType] { + unknown = append(unknown, ruleType) + } + } + sort.Strings(unknown) + + if len(missing) > 0 { + t.Errorf("the Control API has %d rule type(s) the provider does not implement: %v\n"+ + "Each needs a resource (see DEVELOPMENT.md \"Adding a new integration rule\") and an entry in RuleTypeResources.", + len(missing), missing) + } + if len(unknown) > 0 { + t.Errorf("RuleTypeResources names %d rule type(s) that are not in the spec: %v\n"+ + "Either the spec renamed them or the mapping has a typo; a wrong discriminator is rejected by the API at apply time.", + len(unknown), unknown) + } +} + +// specRuleTypes returns every ruleType in the spec's rule_post discriminator. +func specRuleTypes(t *testing.T) []string { + t.Helper() + + doc := loadSpec(t) + components, _ := doc["components"].(map[string]any) + schemas, _ := components["schemas"].(map[string]any) + rulePost, _ := schemas["rule_post"].(map[string]any) + discriminator, _ := rulePost["discriminator"].(map[string]any) + mapping, _ := discriminator["mapping"].(map[string]any) + + types := make([]string, 0, len(mapping)) + for ruleType := range mapping { + types = append(types, ruleType) + } + sort.Strings(types) + return types +} + +// specOperations is every operation in the Control API spec, and what the +// provider does with it. It exists so that new API surface cannot land unnoticed: +// TestSpecOperationCoverage fails on any operation that is not listed here, which +// turns "the API grew" into a decision someone has to make and write down rather +// than something we find out about from a support ticket. +// +// Adding an entry is the deliberate act of recording that decision. Say what the +// provider does, or why it deliberately does not. +var specOperations = map[string]string{ + // Apps. + "get /accounts/{account_id}/apps": "ably_app read, plus the ably_app and ably_apps data sources (the API has no GET by app ID, so all three list and filter)", + "post /accounts/{account_id}/apps": "ably_app create", + "patch /apps/{id}": "ably_app update", + "delete /apps/{id}": "ably_app delete", + "post /apps/{id}/pkcs12": "NOT IMPLEMENTED: uploads APNs config from a binary .p12 archive. " + + "control.UpdateAppPKCS12 exists but nothing calls it; ably_app takes PEM apns_certificate/apns_private_key instead. " + + "Terraform has no natural home for a binary upload side effect, so this stays out until someone needs it.", + + // Keys. + "get /apps/{app_id}/keys": "ably_api_key read, plus the ably_api_key and ably_api_keys data sources (no GET by key ID; all three list and filter)", + "post /apps/{app_id}/keys": "ably_api_key create", + "patch /apps/{app_id}/keys/{key_id}": "ably_api_key update", + "post /apps/{app_id}/keys/{key_id}/revoke": "ably_api_key delete (keys are revoked, not deleted)", + + // Namespaces. + "get /apps/{app_id}/namespaces": "ably_namespace read, plus the ably_namespace and ably_namespaces data sources (no GET by namespace ID; all three list and filter)", + "post /apps/{app_id}/namespaces": "ably_namespace create", + "patch /apps/{app_id}/namespaces/{namespace_id}": "ably_namespace update", + "delete /apps/{app_id}/namespaces/{namespace_id}": "ably_namespace delete", + + // Queues. + "get /apps/{app_id}/queues": "ably_queue read, plus the ably_queue and ably_queues data sources (no GET by queue ID; all three list and filter)", + "post /apps/{app_id}/queues": "ably_queue create", + "delete /apps/{app_id}/queues/{queue_id}": "ably_queue delete (the API has no queue update, hence RequiresReplace on every attribute)", + + // Rules. Which rule types are covered is TestSpecRuleTypeCoverage's job. + "get /apps/{app_id}/rules": "rule resources read (also how the exporter walks an account)", + "get /apps/{app_id}/rules/{rule_id}": "rule resources read", + "post /apps/{app_id}/rules": "rule resources create", + "patch /apps/{app_id}/rules/{rule_id}": "rule resources update", + "delete /apps/{app_id}/rules/{rule_id}": "rule resources delete", + + // Read-only surface. + "get /me": "ably_me data source", + "get /apps/{id}/stats": "NOT IMPLEMENTED: app statistics. Read-only time series whose entries map has no fixed shape, " + + "so it does not map cleanly onto framework types. Deferred deliberately.", + "get /accounts/{id}/stats": "NOT IMPLEMENTED: account statistics. Same shape problem as app stats.", +} + +// TestSpecOperationCoverage fails when the spec gains an operation that +// specOperations does not account for, or when specOperations describes one the +// spec no longer has. +func TestSpecOperationCoverage(t *testing.T) { + t.Parallel() + + operations := specOperationList(t) + if len(operations) == 0 { + t.Fatal("found no operations in the spec; its paths section has moved and this test needs updating") + } + + inSpec := make(map[string]bool, len(operations)) + var undocumented []string + for _, operation := range operations { + inSpec[operation] = true + if _, ok := specOperations[operation]; !ok { + undocumented = append(undocumented, operation) + } + } + + var stale []string + for operation := range specOperations { + if !inSpec[operation] { + stale = append(stale, operation) + } + } + sort.Strings(stale) + + if len(undocumented) > 0 { + t.Errorf("the Control API has %d operation(s) this provider has not accounted for: %v\n"+ + "Decide what the provider should do with each, then record it in specOperations in this file.", + len(undocumented), undocumented) + } + if len(stale) > 0 { + t.Errorf("specOperations describes %d operation(s) the spec no longer has: %v\n"+ + "The API changed shape; drop or update those entries, and check whether the resources relying on them still work.", + len(stale), stale) + } +} + +// specOperationList returns every "method path" pair in the spec. +func specOperationList(t *testing.T) []string { + t.Helper() + + doc := loadSpec(t) + paths, _ := doc["paths"].(map[string]any) + + methods := map[string]bool{"get": true, "post": true, "patch": true, "put": true, "delete": true} + + var operations []string + for path, item := range paths { + operationsByMethod, ok := item.(map[string]any) + if !ok { + continue + } + for method := range operationsByMethod { + if methods[method] { + operations = append(operations, method+" "+path) + } + } + } + sort.Strings(operations) + return operations +} diff --git a/templates/data-sources/api_key.md.tmpl b/templates/data-sources/api_key.md.tmpl new file mode 100644 index 0000000..9c857a4 --- /dev/null +++ b/templates/data-sources/api_key.md.tmpl @@ -0,0 +1,17 @@ +--- +page_title: "{{.Name}} {{.Type}} - {{.ProviderName}}" +subcategory: "" +description: |- +{{ .Description | plainmarkdown | trimspace | prefixlines " " }} +--- + +# {{.Name}} ({{.Type}}) + +{{ .Description | trimspace }} + + +## Example Usage + +{{ tffile "examples/data-sources/api_key.tf" }} + +{{ .SchemaMarkdown | trimspace }} diff --git a/templates/data-sources/api_keys.md.tmpl b/templates/data-sources/api_keys.md.tmpl new file mode 100644 index 0000000..12ca338 --- /dev/null +++ b/templates/data-sources/api_keys.md.tmpl @@ -0,0 +1,17 @@ +--- +page_title: "{{.Name}} {{.Type}} - {{.ProviderName}}" +subcategory: "" +description: |- +{{ .Description | plainmarkdown | trimspace | prefixlines " " }} +--- + +# {{.Name}} ({{.Type}}) + +{{ .Description | trimspace }} + + +## Example Usage + +{{ tffile "examples/data-sources/api_keys.tf" }} + +{{ .SchemaMarkdown | trimspace }} diff --git a/templates/data-sources/app.md.tmpl b/templates/data-sources/app.md.tmpl new file mode 100644 index 0000000..bcd337a --- /dev/null +++ b/templates/data-sources/app.md.tmpl @@ -0,0 +1,17 @@ +--- +page_title: "{{.Name}} {{.Type}} - {{.ProviderName}}" +subcategory: "" +description: |- +{{ .Description | plainmarkdown | trimspace | prefixlines " " }} +--- + +# {{.Name}} ({{.Type}}) + +{{ .Description | trimspace }} + + +## Example Usage + +{{ tffile "examples/data-sources/app.tf" }} + +{{ .SchemaMarkdown | trimspace }} diff --git a/templates/data-sources/apps.md.tmpl b/templates/data-sources/apps.md.tmpl new file mode 100644 index 0000000..b4fb55d --- /dev/null +++ b/templates/data-sources/apps.md.tmpl @@ -0,0 +1,17 @@ +--- +page_title: "{{.Name}} {{.Type}} - {{.ProviderName}}" +subcategory: "" +description: |- +{{ .Description | plainmarkdown | trimspace | prefixlines " " }} +--- + +# {{.Name}} ({{.Type}}) + +{{ .Description | trimspace }} + + +## Example Usage + +{{ tffile "examples/data-sources/apps.tf" }} + +{{ .SchemaMarkdown | trimspace }} diff --git a/templates/data-sources/me.md.tmpl b/templates/data-sources/me.md.tmpl new file mode 100644 index 0000000..d78f023 --- /dev/null +++ b/templates/data-sources/me.md.tmpl @@ -0,0 +1,17 @@ +--- +page_title: "{{.Name}} {{.Type}} - {{.ProviderName}}" +subcategory: "" +description: |- +{{ .Description | plainmarkdown | trimspace | prefixlines " " }} +--- + +# {{.Name}} ({{.Type}}) + +{{ .Description | trimspace }} + + +## Example Usage + +{{ tffile "examples/data-sources/me.tf" }} + +{{ .SchemaMarkdown | trimspace }} diff --git a/templates/data-sources/namespace.md.tmpl b/templates/data-sources/namespace.md.tmpl new file mode 100644 index 0000000..dbea247 --- /dev/null +++ b/templates/data-sources/namespace.md.tmpl @@ -0,0 +1,17 @@ +--- +page_title: "{{.Name}} {{.Type}} - {{.ProviderName}}" +subcategory: "" +description: |- +{{ .Description | plainmarkdown | trimspace | prefixlines " " }} +--- + +# {{.Name}} ({{.Type}}) + +{{ .Description | trimspace }} + + +## Example Usage + +{{ tffile "examples/data-sources/namespace.tf" }} + +{{ .SchemaMarkdown | trimspace }} diff --git a/templates/data-sources/namespaces.md.tmpl b/templates/data-sources/namespaces.md.tmpl new file mode 100644 index 0000000..3830540 --- /dev/null +++ b/templates/data-sources/namespaces.md.tmpl @@ -0,0 +1,17 @@ +--- +page_title: "{{.Name}} {{.Type}} - {{.ProviderName}}" +subcategory: "" +description: |- +{{ .Description | plainmarkdown | trimspace | prefixlines " " }} +--- + +# {{.Name}} ({{.Type}}) + +{{ .Description | trimspace }} + + +## Example Usage + +{{ tffile "examples/data-sources/namespaces.tf" }} + +{{ .SchemaMarkdown | trimspace }} diff --git a/templates/data-sources/queue.md.tmpl b/templates/data-sources/queue.md.tmpl new file mode 100644 index 0000000..a387375 --- /dev/null +++ b/templates/data-sources/queue.md.tmpl @@ -0,0 +1,17 @@ +--- +page_title: "{{.Name}} {{.Type}} - {{.ProviderName}}" +subcategory: "" +description: |- +{{ .Description | plainmarkdown | trimspace | prefixlines " " }} +--- + +# {{.Name}} ({{.Type}}) + +{{ .Description | trimspace }} + + +## Example Usage + +{{ tffile "examples/data-sources/queue.tf" }} + +{{ .SchemaMarkdown | trimspace }} diff --git a/templates/data-sources/queues.md.tmpl b/templates/data-sources/queues.md.tmpl new file mode 100644 index 0000000..3a497f9 --- /dev/null +++ b/templates/data-sources/queues.md.tmpl @@ -0,0 +1,17 @@ +--- +page_title: "{{.Name}} {{.Type}} - {{.ProviderName}}" +subcategory: "" +description: |- +{{ .Description | plainmarkdown | trimspace | prefixlines " " }} +--- + +# {{.Name}} ({{.Type}}) + +{{ .Description | trimspace }} + + +## Example Usage + +{{ tffile "examples/data-sources/queues.tf" }} + +{{ .SchemaMarkdown | trimspace }} diff --git a/templates/resources/rule_azure_moderation.md.tmpl b/templates/resources/rule_azure_moderation.md.tmpl new file mode 100644 index 0000000..9595dfd --- /dev/null +++ b/templates/resources/rule_azure_moderation.md.tmpl @@ -0,0 +1,17 @@ +--- +page_title: "{{.Name}} {{.Type}} - {{.ProviderName}}" +subcategory: "" +description: |- +{{ .Description | plainmarkdown | trimspace | prefixlines " " }} +--- + +# {{.Name}} ({{.Type}}) + +{{ .Description | trimspace }} + + +## Example Usage + +{{ tffile "examples/resources/rule_azure_moderation.tf" }} + +{{ .SchemaMarkdown | trimspace }} diff --git a/templates/resources/rule_before_publish_lambda.md.tmpl b/templates/resources/rule_before_publish_lambda.md.tmpl new file mode 100644 index 0000000..98cba9d --- /dev/null +++ b/templates/resources/rule_before_publish_lambda.md.tmpl @@ -0,0 +1,17 @@ +--- +page_title: "{{.Name}} {{.Type}} - {{.ProviderName}}" +subcategory: "" +description: |- +{{ .Description | plainmarkdown | trimspace | prefixlines " " }} +--- + +# {{.Name}} ({{.Type}}) + +{{ .Description | trimspace }} + + +## Example Usage + +{{ tffile "examples/resources/rule_before_publish_lambda.tf" }} + +{{ .SchemaMarkdown | trimspace }} diff --git a/templates/resources/rule_before_publish_webhook.md.tmpl b/templates/resources/rule_before_publish_webhook.md.tmpl new file mode 100644 index 0000000..d285819 --- /dev/null +++ b/templates/resources/rule_before_publish_webhook.md.tmpl @@ -0,0 +1,17 @@ +--- +page_title: "{{.Name}} {{.Type}} - {{.ProviderName}}" +subcategory: "" +description: |- +{{ .Description | plainmarkdown | trimspace | prefixlines " " }} +--- + +# {{.Name}} ({{.Type}}) + +{{ .Description | trimspace }} + + +## Example Usage + +{{ tffile "examples/resources/rule_before_publish_webhook.tf" }} + +{{ .SchemaMarkdown | trimspace }} diff --git a/templates/resources/rule_hive_dashboard.md.tmpl b/templates/resources/rule_hive_dashboard.md.tmpl new file mode 100644 index 0000000..3e43a29 --- /dev/null +++ b/templates/resources/rule_hive_dashboard.md.tmpl @@ -0,0 +1,17 @@ +--- +page_title: "{{.Name}} {{.Type}} - {{.ProviderName}}" +subcategory: "" +description: |- +{{ .Description | plainmarkdown | trimspace | prefixlines " " }} +--- + +# {{.Name}} ({{.Type}}) + +{{ .Description | trimspace }} + + +## Example Usage + +{{ tffile "examples/resources/rule_hive_dashboard.tf" }} + +{{ .SchemaMarkdown | trimspace }} diff --git a/templates/resources/rule_hive_text.md.tmpl b/templates/resources/rule_hive_text.md.tmpl new file mode 100644 index 0000000..35bac4b --- /dev/null +++ b/templates/resources/rule_hive_text.md.tmpl @@ -0,0 +1,17 @@ +--- +page_title: "{{.Name}} {{.Type}} - {{.ProviderName}}" +subcategory: "" +description: |- +{{ .Description | plainmarkdown | trimspace | prefixlines " " }} +--- + +# {{.Name}} ({{.Type}}) + +{{ .Description | trimspace }} + + +## Example Usage + +{{ tffile "examples/resources/rule_hive_text.tf" }} + +{{ .SchemaMarkdown | trimspace }} diff --git a/templates/resources/rule_tisane.md.tmpl b/templates/resources/rule_tisane.md.tmpl new file mode 100644 index 0000000..be8ef1a --- /dev/null +++ b/templates/resources/rule_tisane.md.tmpl @@ -0,0 +1,17 @@ +--- +page_title: "{{.Name}} {{.Type}} - {{.ProviderName}}" +subcategory: "" +description: |- +{{ .Description | plainmarkdown | trimspace | prefixlines " " }} +--- + +# {{.Name}} ({{.Type}}) + +{{ .Description | trimspace }} + + +## Example Usage + +{{ tffile "examples/resources/rule_tisane.tf" }} + +{{ .SchemaMarkdown | trimspace }}