diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8bd37f0..3b62c93 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,6 +1,27 @@
# Changelog
-## Unreleased
+## 0.2.0 (Unreleased)
+
+BREAKING CHANGES:
+
+* `safe_http_url` and `safe_wss_url` carry `REPLACE_WITH_TOKEN` in place of `TOKEN`.
+* `quicknode_endpoint` no longer has `tokens`, `http_url_with_token` or
+ `wss_url_with_token`. Adding or removing a token changed them, so they went stale
+ after every apply that touched a `quicknode_endpoint_token`. Read working URLs from
+ `data.quicknode_endpoint_urls` or `quicknode_endpoint_token.http_url_with_token`.
+* `quicknode_endpoint.security_options` no longer has `request_filters`. The Admin API
+ sets it when a filter exists, so it went stale after every apply that created one.
+ `data.quicknode_endpoint` still reports it.
+* Removing `label` from a `quicknode_endpoint` clears the endpoint's label.
+
+FEATURES:
+
+* **New Data Source:** `quicknode_endpoint_urls`, with `safe_multichain_urls` and
+ `multichain_urls_with_token` for every network a multichain endpoint serves.
+* `quicknode_endpoint_token` has `http_url_with_token` and `wss_url_with_token`,
+ carrying that token.
+
+## 0.1.0
FEATURES:
diff --git a/api/admin/admin.gen.go b/api/admin/admin.gen.go
index ca1f0b7..3453b8b 100644
--- a/api/admin/admin.gen.go
+++ b/api/admin/admin.gen.go
@@ -10633,16 +10633,10 @@ type GetV0EndpointsByIdUrlsResponse struct {
// HttpUrl The HTTP URL to access the endpoint
HttpUrl *string `json:"http_url,omitempty"`
- // MultichainUrls Only present for multichain endpoints. An object keyed by network identifier (e.g. `avalanche-mainnet`), where each value contains the HTTP and WebSocket URLs for that network.
- MultichainUrls *struct {
- // Network Per-network URL entry. The key is the network identifier (e.g. `avalanche-mainnet`).
- Network *struct {
- // HttpUrl The HTTP URL to access the endpoint on this network
- HttpUrl *string `json:"http_url,omitempty"`
-
- // WssUrl The WebSocket URL to access the endpoint on this network
- WssUrl *string `json:"wss_url,omitempty"`
- } `json:"{network},omitempty"`
+ // MultichainUrls Only present for multichain endpoints. Keyed by network slug, each value holds the network's HTTP and WebSocket URLs.
+ MultichainUrls *map[string]struct {
+ HttpUrl *string `json:"http_url,omitempty"`
+ WssUrl *string `json:"wss_url,omitempty"`
} `json:"multichain_urls,omitempty"`
// WssUrl The WebSocket URL to access the endpoint
@@ -10661,16 +10655,10 @@ func (r GetV0EndpointsByIdUrlsResponse) GetJSON200() *struct {
// HttpUrl The HTTP URL to access the endpoint
HttpUrl *string `json:"http_url,omitempty"`
- // MultichainUrls Only present for multichain endpoints. An object keyed by network identifier (e.g. `avalanche-mainnet`), where each value contains the HTTP and WebSocket URLs for that network.
- MultichainUrls *struct {
- // Network Per-network URL entry. The key is the network identifier (e.g. `avalanche-mainnet`).
- Network *struct {
- // HttpUrl The HTTP URL to access the endpoint on this network
- HttpUrl *string `json:"http_url,omitempty"`
-
- // WssUrl The WebSocket URL to access the endpoint on this network
- WssUrl *string `json:"wss_url,omitempty"`
- } `json:"{network},omitempty"`
+ // MultichainUrls Only present for multichain endpoints. Keyed by network slug, each value holds the network's HTTP and WebSocket URLs.
+ MultichainUrls *map[string]struct {
+ HttpUrl *string `json:"http_url,omitempty"`
+ WssUrl *string `json:"wss_url,omitempty"`
} `json:"multichain_urls,omitempty"`
// WssUrl The WebSocket URL to access the endpoint
@@ -15920,16 +15908,10 @@ func ParseGetV0EndpointsByIdUrlsResponse(rsp *http.Response) (*GetV0EndpointsByI
// HttpUrl The HTTP URL to access the endpoint
HttpUrl *string `json:"http_url,omitempty"`
- // MultichainUrls Only present for multichain endpoints. An object keyed by network identifier (e.g. `avalanche-mainnet`), where each value contains the HTTP and WebSocket URLs for that network.
- MultichainUrls *struct {
- // Network Per-network URL entry. The key is the network identifier (e.g. `avalanche-mainnet`).
- Network *struct {
- // HttpUrl The HTTP URL to access the endpoint on this network
- HttpUrl *string `json:"http_url,omitempty"`
-
- // WssUrl The WebSocket URL to access the endpoint on this network
- WssUrl *string `json:"wss_url,omitempty"`
- } `json:"{network},omitempty"`
+ // MultichainUrls Only present for multichain endpoints. Keyed by network slug, each value holds the network's HTTP and WebSocket URLs.
+ MultichainUrls *map[string]struct {
+ HttpUrl *string `json:"http_url,omitempty"`
+ WssUrl *string `json:"wss_url,omitempty"`
} `json:"multichain_urls,omitempty"`
// WssUrl The WebSocket URL to access the endpoint
diff --git a/api/admin/openapi.json b/api/admin/openapi.json
index be2f577..eaeeebb 100644
--- a/api/admin/openapi.json
+++ b/api/admin/openapi.json
@@ -4389,23 +4389,21 @@
"type": "string"
},
"multichain_urls": {
- "description": "Only present for multichain endpoints. An object keyed by network identifier (e.g. `avalanche-mainnet`), where each value contains the HTTP and WebSocket URLs for that network.",
- "properties": {
- "{network}": {
- "description": "Per-network URL entry. The key is the network identifier (e.g. `avalanche-mainnet`).",
- "properties": {
- "http_url": {
- "description": "The HTTP URL to access the endpoint on this network",
- "type": "string"
- },
- "wss_url": {
- "description": "The WebSocket URL to access the endpoint on this network",
- "type": "string"
- }
+ "additionalProperties": {
+ "properties": {
+ "http_url": {
+ "type": "string"
},
- "type": "object"
- }
+ "wss_url": {
+ "type": [
+ "string",
+ "null"
+ ]
+ }
+ },
+ "type": "object"
},
+ "description": "Only present for multichain endpoints. Keyed by network slug, each value holds the network's HTTP and WebSocket URLs.",
"type": "object"
},
"wss_url": {
diff --git a/api/admin/patches.json b/api/admin/patches.json
index b1921f3..f69db06 100644
--- a/api/admin/patches.json
+++ b/api/admin/patches.json
@@ -350,5 +350,27 @@
}
}
}
+ },
+ {
+ "op": "set",
+ "path": "/paths/~1v0~1endpoints~1{id}~1urls/get/responses/200/content/application~1json/schema/properties/data/properties/multichain_urls",
+ "value": {
+ "description": "Only present for multichain endpoints. Keyed by network slug, each value holds the network's HTTP and WebSocket URLs.",
+ "type": "object",
+ "additionalProperties": {
+ "type": "object",
+ "properties": {
+ "http_url": {
+ "type": "string"
+ },
+ "wss_url": {
+ "type": [
+ "string",
+ "null"
+ ]
+ }
+ }
+ }
+ }
}
]
diff --git a/docs/data-sources/endpoint.md b/docs/data-sources/endpoint.md
index 6e9e865..7201be6 100644
--- a/docs/data-sources/endpoint.md
+++ b/docs/data-sources/endpoint.md
@@ -48,8 +48,8 @@ resource "quicknode_endpoint_ip" "office" {
- `ip_custom_header` (String) Header the endpoint reads the caller's IP address from, or null if none is set.
- `multichain` (Boolean) Whether the endpoint serves more than one network.
- `network` (String) Network slug.
-- `safe_http_url` (String) The HTTPS URL with the auth token replaced by `TOKEN`. Safe to log or display.
-- `safe_wss_url` (String) The WebSocket URL with the auth token replaced by `TOKEN`, or null on chains without WebSocket support.
+- `safe_http_url` (String) The HTTPS URL with the auth token replaced by `REPLACE_WITH_TOKEN`. Safe to log or display.
+- `safe_wss_url` (String) The WebSocket URL with the auth token replaced by `REPLACE_WITH_TOKEN`, or null on chains without WebSocket support.
- `security_options` (Attributes) Which security mechanisms the endpoint enforces. (see [below for nested schema](#nestedatt--security_options))
- `status` (String) `active` or `paused`.
- `tags` (List of String) Tag labels applied to the endpoint.
diff --git a/docs/data-sources/endpoint_urls.md b/docs/data-sources/endpoint_urls.md
new file mode 100644
index 0000000..bcb0573
--- /dev/null
+++ b/docs/data-sources/endpoint_urls.md
@@ -0,0 +1,83 @@
+---
+# generated by https://github.com/hashicorp/terraform-plugin-docs
+page_title: "quicknode_endpoint_urls Data Source - quicknode"
+subcategory: ""
+description: |-
+ The URLs an endpoint serves, read fresh on every plan. The credentialed URLs carry whichever token the Admin API currently embeds, which changes when tokens are added or removed, so they are read here and not tracked on quicknode_endpoint.
+ A multichain endpoint also serves every network in safe_multichain_urls and multichain_urls_with_token, keyed by network slug. Both maps are empty when multichain is off.
+ Set depends_on to the endpoint, or to the quicknode_endpoint_token resources on it, when they are in the same configuration. The id is known before the apply, so without it the URLs are read during the plan, before a change to multichain or to the tokens is applied.
+---
+
+# quicknode_endpoint_urls (Data Source)
+
+The URLs an endpoint serves, read fresh on every plan. The credentialed URLs carry whichever token the Admin API currently embeds, which changes when tokens are added or removed, so they are read here and not tracked on `quicknode_endpoint`.
+
+A multichain endpoint also serves every network in `safe_multichain_urls` and `multichain_urls_with_token`, keyed by network slug. Both maps are empty when `multichain` is off.
+
+Set `depends_on` to the endpoint, or to the `quicknode_endpoint_token` resources on it, when they are in the same configuration. The id is known before the apply, so without it the URLs are read during the plan, before a change to `multichain` or to the tokens is applied.
+
+## Example Usage
+
+```terraform
+resource "quicknode_endpoint" "api" {
+ chain = "eth"
+ network = "mainnet"
+ multichain = true
+}
+
+# depends_on defers the read to the apply, so it sees multichain enabled.
+data "quicknode_endpoint_urls" "api" {
+ endpoint_id = quicknode_endpoint.api.id
+ depends_on = [quicknode_endpoint.api]
+}
+
+# Pass the credentialed URL to whatever makes RPC calls.
+output "rpc_url" {
+ value = data.quicknode_endpoint_urls.api.http_url_with_token
+ sensitive = true
+}
+
+# The same endpoint on another network, keyed by network slug.
+output "base_rpc_url" {
+ value = data.quicknode_endpoint_urls.api.multichain_urls_with_token["base-mainnet"].http_url
+ sensitive = true
+}
+
+# Every network the endpoint serves, safe to log or display.
+output "networks" {
+ value = keys(data.quicknode_endpoint_urls.api.safe_multichain_urls)
+}
+```
+
+
+## Schema
+
+### Required
+
+- `endpoint_id` (String) Endpoint id.
+
+### Read-Only
+
+- `http_url_with_token` (String, Sensitive) The working HTTPS endpoint, exactly as the Admin API returns it.
+- `multichain_urls_with_token` (Attributes Map, Sensitive) Every network a multichain endpoint serves, with working URLs. (see [below for nested schema](#nestedatt--multichain_urls_with_token))
+- `safe_http_url` (String) The HTTPS URL with the auth token replaced by `REPLACE_WITH_TOKEN`. Safe to log or display.
+- `safe_multichain_urls` (Attributes Map) Every network a multichain endpoint serves, with the auth token replaced by `REPLACE_WITH_TOKEN`. Safe to log or display. (see [below for nested schema](#nestedatt--safe_multichain_urls))
+- `safe_wss_url` (String) The WebSocket URL with the auth token replaced by `REPLACE_WITH_TOKEN`, or null on chains without WebSocket support.
+- `wss_url_with_token` (String, Sensitive) The working WebSocket endpoint, or null on chains without WebSocket support.
+
+
+### Nested Schema for `multichain_urls_with_token`
+
+Read-Only:
+
+- `http_url` (String) The network's HTTPS URL.
+- `wss_url` (String) The network's WebSocket URL, or null on networks without WebSocket support.
+
+
+
+### Nested Schema for `safe_multichain_urls`
+
+Read-Only:
+
+- `http_url` (String) The network's HTTPS URL.
+- `wss_url` (String) The network's WebSocket URL, or null on networks without WebSocket support.
diff --git a/docs/data-sources/endpoints.md b/docs/data-sources/endpoints.md
index b399188..66f05a2 100644
--- a/docs/data-sources/endpoints.md
+++ b/docs/data-sources/endpoints.md
@@ -65,7 +65,7 @@ Read-Only:
- `multichain` (Boolean) Whether the endpoint serves more than one network.
- `name` (String) Endpoint subdomain.
- `network` (String) Network slug.
-- `safe_http_url` (String) The HTTPS URL with the auth token replaced by `TOKEN`. Safe to log or display. The list route carries no usable token, so read `data.quicknode_endpoint` for a working URL.
-- `safe_wss_url` (String) The WebSocket URL with the auth token replaced by `TOKEN`, or null on chains without WebSocket support.
+- `safe_http_url` (String) The HTTPS URL with the auth token replaced by `REPLACE_WITH_TOKEN`. Safe to log or display. The list route carries no usable token, so read `data.quicknode_endpoint` for a working URL.
+- `safe_wss_url` (String) The WebSocket URL with the auth token replaced by `REPLACE_WITH_TOKEN`, or null on chains without WebSocket support.
- `status` (String) `active` or `paused`.
- `tags` (List of String) Tag labels applied to the endpoint.
diff --git a/docs/index.md b/docs/index.md
index 5a6e664..63813c2 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -37,7 +37,7 @@ provider "quicknode" {}
## What you can manage
-| | |
+| Resource | Manages |
|---|---|
| `quicknode_endpoint` | the endpoint itself, its label, status, tags and which security mechanisms it enforces |
| `quicknode_endpoint_ip`, `_domain_mask`, `_referrer` | who is allowed to call it |
@@ -46,6 +46,7 @@ provider "quicknode" {}
| `quicknode_endpoint_request_filter` | which RPC methods it accepts |
| `quicknode_endpoint_rate_limits`, `_method_rate_limit` | how much traffic it accepts, overall and per method |
| `data.quicknode_endpoint`, `data.quicknode_endpoints` | endpoints created elsewhere |
+| `data.quicknode_endpoint_urls` | an endpoint's working URLs, including every network of a multichain endpoint |
| `data.quicknode_chains` | every chain and network slug, for validating configuration at plan time |
A security mechanism is enabled on the endpoint and the entries it applies to
@@ -55,24 +56,29 @@ than deleted on the next apply.
## Endpoint URLs
The Admin API returns endpoint URLs with the auth token embedded in the path.
-Endpoints expose both forms:
+The provider exposes two forms:
-| Attribute | Sensitive | Use it for |
-|---|---|---|
-| `http_url_with_token`, `wss_url_with_token` | yes | anything that makes RPC calls |
-| `safe_http_url`, `safe_wss_url` | no | logging, display, anything that must not hold a credential |
+| Attribute | Sensitive | Where | Use it for |
+|---|---|---|---|
+| `http_url_with_token`, `wss_url_with_token` | yes | `data.quicknode_endpoint_urls`, `quicknode_endpoint_token` | anything that makes RPC calls |
+| `safe_http_url`, `safe_wss_url` | no | `quicknode_endpoint` and the data sources | logging, display, anything that must not hold a credential |
-The safe form carries the literal `TOKEN` where the credential belongs:
+The URL the Admin API returns carries one of the endpoint's tokens, and which
+one changes as tokens are added and removed. So `quicknode_endpoint` only keeps
+the safe form, and the working URLs are read from `data.quicknode_endpoint_urls`
+or taken from the `quicknode_endpoint_token` that issued the credential.
+
+The safe form carries the literal `REPLACE_WITH_TOKEN` where the credential belongs:
```
-https://example-name.hype-testnet.quiknode.pro/TOKEN/evm
+https://example-name.hype-testnet.quiknode.pro/REPLACE_WITH_TOKEN/evm
```
It keeps the real URL's shape, so substituting a token reproduces a working
address on every chain:
```hcl
-replace(quicknode_endpoint.api.safe_http_url, "TOKEN", var.token)
+replace(quicknode_endpoint.api.safe_http_url, "REPLACE_WITH_TOKEN", var.token)
```
Do not assemble a URL from parts instead. The token is not always the last path
diff --git a/docs/resources/endpoint.md b/docs/resources/endpoint.md
index e96df81..464adc5 100644
--- a/docs/resources/endpoint.md
+++ b/docs/resources/endpoint.md
@@ -4,14 +4,17 @@ page_title: "quicknode_endpoint Resource - quicknode"
subcategory: ""
description: |-
A Quicknode RPC endpoint on a chain and network.
- Pass http_url_with_token to anything that needs to make RPC calls. safe_http_url and safe_wss_url carry the literal TOKEN where the credential belongs, so they are safe to log or display while keeping the real URL's shape, including any path suffix the chain appends.
+ safe_http_url and safe_wss_url carry the literal REPLACE_WITH_TOKEN where the credential belongs, so they are safe to log or display while keeping the real URL's shape, including any path suffix the chain appends.
+ The resource does not track the endpoint's tokens or the URLs that carry them, because adding or removing a token changes both. Read a working URL from data.quicknode_endpoint_urls, or from the quicknode_endpoint_token that issued the credential.
---
# quicknode_endpoint (Resource)
A Quicknode RPC endpoint on a chain and network.
-Pass `http_url_with_token` to anything that needs to make RPC calls. `safe_http_url` and `safe_wss_url` carry the literal `TOKEN` where the credential belongs, so they are safe to log or display while keeping the real URL's shape, including any path suffix the chain appends.
+`safe_http_url` and `safe_wss_url` carry the literal `REPLACE_WITH_TOKEN` where the credential belongs, so they are safe to log or display while keeping the real URL's shape, including any path suffix the chain appends.
+
+The resource does not track the endpoint's tokens or the URLs that carry them, because adding or removing a token changes both. Read a working URL from `data.quicknode_endpoint_urls`, or from the `quicknode_endpoint_token` that issued the credential.
## Example Usage
@@ -37,18 +40,23 @@ resource "quicknode_endpoint" "payments" {
ip_custom_header = "X-Real-IP"
}
-# Pass the credentialed URL to whatever makes RPC calls.
-output "payments_rpc_url" {
- value = quicknode_endpoint.payments.http_url_with_token
- sensitive = true
-}
-
-# The same URL with the credential replaced by the literal TOKEN. Safe to log
-# or display, and it keeps the real URL's shape, so substituting a token
+# The URL with the credential replaced by the literal REPLACE_WITH_TOKEN. Safe
+# to log or display, and it keeps the real URL's shape, so substituting a token
# reproduces a working address on every chain.
output "payments_rpc_url_redacted" {
value = quicknode_endpoint.payments.safe_http_url
}
+
+# The credentialed URL is read from data.quicknode_endpoint_urls, because it
+# changes whenever the endpoint's tokens do.
+data "quicknode_endpoint_urls" "payments" {
+ endpoint_id = quicknode_endpoint.payments.id
+}
+
+output "payments_rpc_url" {
+ value = data.quicknode_endpoint_urls.payments.http_url_with_token
+ sensitive = true
+}
```
@@ -62,7 +70,7 @@ output "payments_rpc_url_redacted" {
### Optional
- `ip_custom_header` (String) Name of the header the endpoint reads the caller's IP address from, for example `X-Real-IP`. Set it when calls arrive through a proxy, so IP restrictions see the original caller's address and not the proxy's.
-- `label` (String) Descriptive label for the endpoint. Labels are not unique and do not identify the endpoint. Quicknode has no route for clearing a label once set, so removing the attribute leaves the current label in place and Terraform stops tracking it.
+- `label` (String) Descriptive label for the endpoint. Labels are not unique and do not identify the endpoint. Removing the attribute clears the label.
- `multichain` (Boolean) Whether the endpoint serves more than one network.
- `security_options` (Attributes) Which security mechanisms the endpoint enforces. Each toggle only decides whether a mechanism is applied; the entries it applies to are separate resources, such as `quicknode_endpoint_ip`. A toggle left out of the configuration keeps whatever value the endpoint already has. (see [below for nested schema](#nestedatt--security_options))
- `status` (String) `active` or `paused`.
@@ -70,12 +78,9 @@ output "payments_rpc_url_redacted" {
### Read-Only
-- `http_url_with_token` (String, Sensitive) The working HTTPS endpoint, exactly as the Admin API returns it. Pass this to whatever makes RPC calls.
- `id` (String) Endpoint id.
-- `safe_http_url` (String) The HTTPS URL with the auth token replaced by `TOKEN`. Safe to log or display. Substitute a real token to make it usable: `replace(self.safe_http_url, "TOKEN", self.tokens[0].token)`.
-- `safe_wss_url` (String) The WebSocket URL with the auth token replaced by `TOKEN`, or null on chains without WebSocket support.
-- `tokens` (Attributes List) Auth tokens for the endpoint. An endpoint can carry several. Token values are stored in Terraform state, so keep state encrypted and remote. (see [below for nested schema](#nestedatt--tokens))
-- `wss_url_with_token` (String, Sensitive) The working WebSocket endpoint, or null on chains without WebSocket support.
+- `safe_http_url` (String) The HTTPS URL with the auth token replaced by `REPLACE_WITH_TOKEN`. Safe to log or display.
+- `safe_wss_url` (String) The WebSocket URL with the auth token replaced by `REPLACE_WITH_TOKEN`, or null on chains without WebSocket support.
### Nested Schema for `security_options`
@@ -92,18 +97,8 @@ Optional:
Read-Only:
-- `request_filters` (Boolean) Whether RPC method filtering is applied. Read-only: the Admin API turns this on when a `quicknode_endpoint_request_filter` exists and off when the last one is removed.
- `response_logging` (Boolean) Whether responses are logged for the endpoint. Read-only: the account's plan sets it and it cannot be changed per endpoint.
-
-
-### Nested Schema for `tokens`
-
-Read-Only:
-
-- `id` (String) Token id.
-- `token` (String, Sensitive) Token value.
-
## Import
Import is supported using the following syntax:
diff --git a/docs/resources/endpoint_request_filter.md b/docs/resources/endpoint_request_filter.md
index 2afa5c9..208cee8 100644
--- a/docs/resources/endpoint_request_filter.md
+++ b/docs/resources/endpoint_request_filter.md
@@ -4,14 +4,14 @@ page_title: "quicknode_endpoint_request_filter Resource - quicknode"
subcategory: ""
description: |-
The set of RPC methods a Quicknode endpoint accepts. Anything outside the set is rejected, which keeps an endpoint handed to a browser or a third party from reaching methods it has no reason to call.
- security_options.request_filters on the endpoint reports whether filtering is applied. It is read-only: the Admin API turns it on when a filter exists and off when the last one is removed.
+ The Admin API turns filtering on when a filter exists and off when the last one is removed. data.quicknode_endpoint reports it as security_options.request_filters.
---
# quicknode_endpoint_request_filter (Resource)
The set of RPC methods a Quicknode endpoint accepts. Anything outside the set is rejected, which keeps an endpoint handed to a browser or a third party from reaching methods it has no reason to call.
-`security_options.request_filters` on the endpoint reports whether filtering is applied. It is read-only: the Admin API turns it on when a filter exists and off when the last one is removed.
+The Admin API turns filtering on when a filter exists and off when the last one is removed. `data.quicknode_endpoint` reports it as `security_options.request_filters`.
## Example Usage
@@ -21,8 +21,8 @@ resource "quicknode_endpoint" "api" {
network = "mainnet"
}
-# Anything outside the set is rejected. security_options.request_filters on the
-# endpoint flips to true on its own once a filter exists.
+# Anything outside the set is rejected. The Admin API turns filtering on once a
+# filter exists.
resource "quicknode_endpoint_request_filter" "read_only" {
endpoint_id = quicknode_endpoint.api.id
diff --git a/docs/resources/endpoint_token.md b/docs/resources/endpoint_token.md
index 3e9d052..6065cdb 100644
--- a/docs/resources/endpoint_token.md
+++ b/docs/resources/endpoint_token.md
@@ -31,8 +31,9 @@ resource "quicknode_endpoint_token" "indexer" {
endpoint_id = quicknode_endpoint.api.id
}
-output "indexer_token" {
- value = quicknode_endpoint_token.indexer.token
+# The endpoint's URL carrying this token, for the consumer it was issued to.
+output "indexer_rpc_url" {
+ value = quicknode_endpoint_token.indexer.http_url_with_token
sensitive = true
}
```
@@ -46,8 +47,10 @@ output "indexer_token" {
### Read-Only
+- `http_url_with_token` (String, Sensitive) The endpoint's HTTPS URL carrying this token. Pass it to the consumer the token was issued for.
- `id` (String) Token id assigned by Quicknode.
- `token` (String, Sensitive) The token value. It is stored in Terraform state, so keep state encrypted and remote.
+- `wss_url_with_token` (String, Sensitive) The endpoint's WebSocket URL carrying this token, or null on chains without WebSocket support.
## Import
diff --git a/examples/data-sources/quicknode_endpoint_urls/data-source.tf b/examples/data-sources/quicknode_endpoint_urls/data-source.tf
new file mode 100644
index 0000000..d87b7a7
--- /dev/null
+++ b/examples/data-sources/quicknode_endpoint_urls/data-source.tf
@@ -0,0 +1,28 @@
+resource "quicknode_endpoint" "api" {
+ chain = "eth"
+ network = "mainnet"
+ multichain = true
+}
+
+# depends_on defers the read to the apply, so it sees multichain enabled.
+data "quicknode_endpoint_urls" "api" {
+ endpoint_id = quicknode_endpoint.api.id
+ depends_on = [quicknode_endpoint.api]
+}
+
+# Pass the credentialed URL to whatever makes RPC calls.
+output "rpc_url" {
+ value = data.quicknode_endpoint_urls.api.http_url_with_token
+ sensitive = true
+}
+
+# The same endpoint on another network, keyed by network slug.
+output "base_rpc_url" {
+ value = data.quicknode_endpoint_urls.api.multichain_urls_with_token["base-mainnet"].http_url
+ sensitive = true
+}
+
+# Every network the endpoint serves, safe to log or display.
+output "networks" {
+ value = keys(data.quicknode_endpoint_urls.api.safe_multichain_urls)
+}
diff --git a/examples/resources/quicknode_endpoint/resource.tf b/examples/resources/quicknode_endpoint/resource.tf
index 158dcd0..08294a4 100644
--- a/examples/resources/quicknode_endpoint/resource.tf
+++ b/examples/resources/quicknode_endpoint/resource.tf
@@ -19,15 +19,20 @@ resource "quicknode_endpoint" "payments" {
ip_custom_header = "X-Real-IP"
}
-# Pass the credentialed URL to whatever makes RPC calls.
-output "payments_rpc_url" {
- value = quicknode_endpoint.payments.http_url_with_token
- sensitive = true
-}
-
-# The same URL with the credential replaced by the literal TOKEN. Safe to log
-# or display, and it keeps the real URL's shape, so substituting a token
+# The URL with the credential replaced by the literal REPLACE_WITH_TOKEN. Safe
+# to log or display, and it keeps the real URL's shape, so substituting a token
# reproduces a working address on every chain.
output "payments_rpc_url_redacted" {
value = quicknode_endpoint.payments.safe_http_url
}
+
+# The credentialed URL is read from data.quicknode_endpoint_urls, because it
+# changes whenever the endpoint's tokens do.
+data "quicknode_endpoint_urls" "payments" {
+ endpoint_id = quicknode_endpoint.payments.id
+}
+
+output "payments_rpc_url" {
+ value = data.quicknode_endpoint_urls.payments.http_url_with_token
+ sensitive = true
+}
diff --git a/examples/resources/quicknode_endpoint_request_filter/resource.tf b/examples/resources/quicknode_endpoint_request_filter/resource.tf
index 63bc620..ceb23cc 100644
--- a/examples/resources/quicknode_endpoint_request_filter/resource.tf
+++ b/examples/resources/quicknode_endpoint_request_filter/resource.tf
@@ -3,8 +3,8 @@ resource "quicknode_endpoint" "api" {
network = "mainnet"
}
-# Anything outside the set is rejected. security_options.request_filters on the
-# endpoint flips to true on its own once a filter exists.
+# Anything outside the set is rejected. The Admin API turns filtering on once a
+# filter exists.
resource "quicknode_endpoint_request_filter" "read_only" {
endpoint_id = quicknode_endpoint.api.id
diff --git a/examples/resources/quicknode_endpoint_token/resource.tf b/examples/resources/quicknode_endpoint_token/resource.tf
index 3914349..72cdd96 100644
--- a/examples/resources/quicknode_endpoint_token/resource.tf
+++ b/examples/resources/quicknode_endpoint_token/resource.tf
@@ -13,7 +13,8 @@ resource "quicknode_endpoint_token" "indexer" {
endpoint_id = quicknode_endpoint.api.id
}
-output "indexer_token" {
- value = quicknode_endpoint_token.indexer.token
+# The endpoint's URL carrying this token, for the consumer it was issued to.
+output "indexer_rpc_url" {
+ value = quicknode_endpoint_token.indexer.http_url_with_token
sensitive = true
}
diff --git a/internal/client/client.go b/internal/client/client.go
index a9da6f1..7bf92cd 100644
--- a/internal/client/client.go
+++ b/internal/client/client.go
@@ -18,7 +18,7 @@ const DefaultBaseURL = "https://api.quicknode.com"
// SafeWSSURL. It keeps the shape of the real URL, including any path suffix the
// chain appends, so the token's position stays visible and a caller can
// substitute one without guessing where it goes.
-const URLTokenPlaceholder = "TOKEN"
+const URLTokenPlaceholder = "REPLACE_WITH_TOKEN"
type Client struct {
api *admin.ClientWithResponses
@@ -280,6 +280,64 @@ func (c *Client) GetEndpoint(ctx context.Context, id string) (*Endpoint, error)
return endpoint, nil
}
+// NetworkURLs is one network's URLs, in the same safe and credentialed forms as
+// Endpoint's.
+type NetworkURLs struct {
+ SafeHTTPURL string
+ SafeWSSURL string
+ HTTPURLWithToken string
+ WSSURLWithToken string
+}
+
+// EndpointURLs is what the URLs route returns: the endpoint's own network, and
+// for a multichain endpoint every network it serves, keyed by network slug.
+type EndpointURLs struct {
+ NetworkURLs
+ Multichain map[string]NetworkURLs
+}
+
+func newNetworkURLs(httpURL, wssURL string) NetworkURLs {
+ return NetworkURLs{
+ SafeHTTPURL: RedactEndpointURL(httpURL),
+ SafeWSSURL: RedactEndpointURL(wssURL),
+ HTTPURLWithToken: httpURL,
+ WSSURLWithToken: wssURL,
+ }
+}
+
+func (c *Client) GetEndpointURLs(ctx context.Context, id string) (*EndpointURLs, error) {
+ const operation = "read endpoint urls"
+
+ resp, err := c.api.GetV0EndpointsByIdUrlsWithResponse(ctx, id)
+ if err != nil {
+ return nil, err
+ }
+ if resp.StatusCode() == http.StatusNotFound {
+ return nil, &Error{Operation: operation, Status: http.StatusNotFound, Message: "endpoint not found"}
+ }
+ if resp.JSON200 == nil {
+ return nil, statusError(operation, resp.StatusCode(), resp.Body)
+ }
+ if err := envelopeError(operation, resp.StatusCode(), resp.JSON200.Error); err != nil {
+ return nil, err
+ }
+ if resp.JSON200.Data == nil {
+ return nil, &Error{Operation: operation, Status: http.StatusNotFound, Message: "endpoint not found"}
+ }
+
+ data := resp.JSON200.Data
+ urls := &EndpointURLs{
+ NetworkURLs: newNetworkURLs(deref(data.HttpUrl), deref(data.WssUrl)),
+ Multichain: map[string]NetworkURLs{},
+ }
+ if data.MultichainUrls != nil {
+ for network, raw := range *data.MultichainUrls {
+ urls.Multichain[network] = newNetworkURLs(deref(raw.HttpUrl), deref(raw.WssUrl))
+ }
+ }
+ return urls, nil
+}
+
func (c *Client) SetEndpointLabel(ctx context.Context, id, label string) error {
const operation = "set endpoint label"
@@ -400,6 +458,12 @@ func (e *Endpoint) setURLs(httpURL, wssURL string) {
// chain, so the placeholder goes in the token's position and nothing is cut
// out. The result keeps the real URL's shape and is safe to log.
func RedactEndpointURL(raw string) string {
+ return EndpointURLWithToken(raw, URLTokenPlaceholder)
+}
+
+// EndpointURLWithToken puts token in the credential's position of an endpoint
+// URL, whether that position holds a real token or URLTokenPlaceholder.
+func EndpointURLWithToken(raw, token string) string {
if raw == "" {
return ""
}
@@ -413,11 +477,11 @@ func RedactEndpointURL(raw string) string {
}
_, suffix, hadSuffix := strings.Cut(path, "/")
- redacted := scheme + "://" + host + "/" + URLTokenPlaceholder
+ rebuilt := scheme + "://" + host + "/" + token
if hadSuffix {
- redacted += "/" + suffix
+ rebuilt += "/" + suffix
}
- return redacted
+ return rebuilt
}
func deref[T any](value *T) T {
diff --git a/internal/client/client_test.go b/internal/client/client_test.go
index fb63902..2cb4eae 100644
--- a/internal/client/client_test.go
+++ b/internal/client/client_test.go
@@ -14,22 +14,22 @@ func TestRedactEndpointURL(t *testing.T) {
{
name: "token followed by a chain suffix",
raw: "https://example-name.hype-testnet.quiknode.pro/abc123/evm",
- want: "https://example-name.hype-testnet.quiknode.pro/TOKEN/evm",
+ want: "https://example-name.hype-testnet.quiknode.pro/REPLACE_WITH_TOKEN/evm",
},
{
name: "token with no suffix",
raw: "https://example-name.quiknode.pro/abc123",
- want: "https://example-name.quiknode.pro/TOKEN",
+ want: "https://example-name.quiknode.pro/REPLACE_WITH_TOKEN",
},
{
name: "trailing slash after the token",
raw: "https://example-name.btc.quiknode.pro/abc123/",
- want: "https://example-name.btc.quiknode.pro/TOKEN/",
+ want: "https://example-name.btc.quiknode.pro/REPLACE_WITH_TOKEN/",
},
{
name: "websocket scheme",
raw: "wss://example-name.quiknode.pro/abc123/evm",
- want: "wss://example-name.quiknode.pro/TOKEN/evm",
+ want: "wss://example-name.quiknode.pro/REPLACE_WITH_TOKEN/evm",
},
{
name: "no path at all",
@@ -70,5 +70,16 @@ func TestRedactedURLKeepsItsShape(t *testing.T) {
if restored != raw {
t.Errorf("substituting the token gave %q, want %q", restored, raw)
}
+ if rebuilt := EndpointURLWithToken(redacted, "abc123"); rebuilt != raw {
+ t.Errorf("EndpointURLWithToken(%q) = %q, want %q", redacted, rebuilt, raw)
+ }
+ }
+}
+
+func TestEndpointURLWithTokenSwapsTokens(t *testing.T) {
+ const raw = "https://example-name.avalanche-mainnet.quiknode.pro/first/ext/bc/C/rpc/"
+ const want = "https://example-name.avalanche-mainnet.quiknode.pro/second/ext/bc/C/rpc/"
+ if got := EndpointURLWithToken(raw, "second"); got != want {
+ t.Errorf("EndpointURLWithToken = %q, want %q", got, want)
}
}
diff --git a/internal/client/endpoint_test.go b/internal/client/endpoint_test.go
index 2fda69d..0e6b905 100644
--- a/internal/client/endpoint_test.go
+++ b/internal/client/endpoint_test.go
@@ -59,14 +59,14 @@ func TestGetEndpointWithPathSuffix(t *testing.T) {
if endpoint.WSSURLWithToken != "wss://example-name.hype-testnet.quiknode.pro/TOKENVALUE/evm" {
t.Errorf("WSSURLWithToken = %q", endpoint.WSSURLWithToken)
}
- if endpoint.SafeWSSURL != "wss://example-name.hype-testnet.quiknode.pro/TOKEN/evm" {
+ if endpoint.SafeWSSURL != "wss://example-name.hype-testnet.quiknode.pro/REPLACE_WITH_TOKEN/evm" {
t.Errorf("SafeWSSURL = %q", endpoint.SafeWSSURL)
}
// The redacted URL keeps the real one's shape, so substituting a token
// reproduces it exactly. That is what the placeholder buys over cutting
// the token out: this chain puts a suffix after it.
- const wantRedacted = "https://example-name.hype-testnet.quiknode.pro/TOKEN/evm"
+ const wantRedacted = "https://example-name.hype-testnet.quiknode.pro/REPLACE_WITH_TOKEN/evm"
if endpoint.SafeHTTPURL != wantRedacted {
t.Errorf("SafeHTTPURL = %q, want %q", endpoint.SafeHTTPURL, wantRedacted)
}
@@ -97,7 +97,7 @@ func TestGetEndpointWithoutWebsocket(t *testing.T) {
if endpoint.HTTPURLWithToken != "https://example-name.btc.quiknode.pro/TOKENVALUE/" {
t.Errorf("HTTPURLWithToken = %q", endpoint.HTTPURLWithToken)
}
- if endpoint.SafeHTTPURL != "https://example-name.btc.quiknode.pro/TOKEN/" {
+ if endpoint.SafeHTTPURL != "https://example-name.btc.quiknode.pro/REPLACE_WITH_TOKEN/" {
t.Errorf("SafeHTTPURL = %q", endpoint.SafeHTTPURL)
}
if endpoint.Status != "paused" || endpoint.Label != "ledger" {
@@ -136,3 +136,52 @@ func TestGetEndpointDecodesLiveBody(t *testing.T) {
t.Errorf("Tokens = %+v", endpoint.Tokens)
}
}
+
+// multichainURLsBody mirrors a live GET /v0/endpoints/{id}/urls response for a
+// multichain endpoint, trimmed to networks that cover a path suffix and a
+// missing WebSocket URL. The token is fabricated.
+const multichainURLsBody = `{"data":{` +
+ `"http_url":"https://example-name.ethereum-sepolia.quiknode.pro/TOKENVALUE/",` +
+ `"wss_url":"wss://example-name.ethereum-sepolia.quiknode.pro/TOKENVALUE/",` +
+ `"multichain_urls":{` +
+ `"avalanche-mainnet":{"http_url":"https://example-name.avalanche-mainnet.quiknode.pro/TOKENVALUE/ext/bc/C/rpc/",` +
+ `"wss_url":"wss://example-name.avalanche-mainnet.quiknode.pro/TOKENVALUE/ext/bc/C/ws/"},` +
+ `"btc":{"http_url":"https://example-name.btc.quiknode.pro/TOKENVALUE/","wss_url":null}}},` +
+ `"error":null}`
+
+func TestGetEndpointURLsDecodesMultichain(t *testing.T) {
+ urls, err := newTestClient(t, multichainURLsBody).GetEndpointURLs(context.Background(), "123456")
+ if err != nil {
+ t.Fatalf("GetEndpointURLs: %v", err)
+ }
+ if urls.SafeHTTPURL != "https://example-name.ethereum-sepolia.quiknode.pro/REPLACE_WITH_TOKEN/" {
+ t.Errorf("SafeHTTPURL = %q", urls.SafeHTTPURL)
+ }
+ if len(urls.Multichain) != 2 {
+ t.Fatalf("Multichain = %+v", urls.Multichain)
+ }
+
+ avalanche := urls.Multichain["avalanche-mainnet"]
+ if avalanche.SafeHTTPURL != "https://example-name.avalanche-mainnet.quiknode.pro/REPLACE_WITH_TOKEN/ext/bc/C/rpc/" {
+ t.Errorf("avalanche SafeHTTPURL = %q", avalanche.SafeHTTPURL)
+ }
+ if avalanche.WSSURLWithToken != "wss://example-name.avalanche-mainnet.quiknode.pro/TOKENVALUE/ext/bc/C/ws/" {
+ t.Errorf("avalanche WSSURLWithToken = %q", avalanche.WSSURLWithToken)
+ }
+
+ bitcoin := urls.Multichain["btc"]
+ if bitcoin.SafeWSSURL != "" || bitcoin.WSSURLWithToken != "" {
+ t.Errorf("btc WebSocket URLs = %q, %q, want empty", bitcoin.SafeWSSURL, bitcoin.WSSURLWithToken)
+ }
+}
+
+func TestGetEndpointURLsWithoutMultichain(t *testing.T) {
+ body := `{"data":{"http_url":"https://example-name.btc.quiknode.pro/TOKENVALUE/","wss_url":null},"error":null}`
+ urls, err := newTestClient(t, body).GetEndpointURLs(context.Background(), "123457")
+ if err != nil {
+ t.Fatalf("GetEndpointURLs: %v", err)
+ }
+ if urls.Multichain == nil || len(urls.Multichain) != 0 {
+ t.Errorf("Multichain = %#v, want an empty map", urls.Multichain)
+ }
+}
diff --git a/internal/provider/acceptance_test.go b/internal/provider/acceptance_test.go
index 398ff6e..1134700 100644
--- a/internal/provider/acceptance_test.go
+++ b/internal/provider/acceptance_test.go
@@ -4,6 +4,8 @@ import (
"context"
"fmt"
"os"
+ "regexp"
+ "strings"
"testing"
"github.com/hashicorp/terraform-plugin-framework/providerserver"
@@ -88,10 +90,8 @@ func TestAccEndpoint_lifecycle(t *testing.T) {
resource.TestCheckResourceAttr("quicknode_endpoint.test", "chain", acceptanceChain),
resource.TestCheckResourceAttr("quicknode_endpoint.test", "label", "tfacc-endpoint"),
resource.TestCheckResourceAttr("quicknode_endpoint.test", "status", "active"),
- // The credentialed URL is the one that works; the stripped
- // URL must not carry the token.
- resource.TestCheckResourceAttrSet("quicknode_endpoint.test", "http_url_with_token"),
- resource.TestCheckResourceAttrSet("quicknode_endpoint.test", "tokens.0.token"),
+ resource.TestMatchResourceAttr("quicknode_endpoint.test", "safe_http_url", regexp.MustCompile(`/REPLACE_WITH_TOKEN/`)),
+ resource.TestCheckNoResourceAttr("quicknode_endpoint.test", "tokens"),
resource.TestCheckResourceAttrSet("quicknode_endpoint.test", "security_options.tokens"),
),
},
@@ -219,7 +219,10 @@ resource "quicknode_endpoint_request_filter" "test" {
Steps: []resource.TestStep{
{
Config: withMethods(`["eth_call"]`),
- Check: resource.TestCheckResourceAttr("quicknode_endpoint_request_filter.test", "methods.#", "1"),
+ Check: resource.ComposeAggregateTestCheckFunc(
+ resource.TestCheckResourceAttr("quicknode_endpoint_request_filter.test", "methods.#", "1"),
+ resource.TestCheckNoResourceAttr("quicknode_endpoint.test", "security_options.request_filters"),
+ ),
},
{
// A real PUT route backs this, so the filter must update rather
@@ -396,10 +399,9 @@ func endpointIDForImport(suffix func(*terraform.State) (string, error)) func(*te
}
}
-// TestAccEndpoint_labelSurvivesRemoval covers the one attribute Quicknode has
-// no route to clear. Dropping it from the configuration has to leave the
-// endpoint's label alone and settle into an empty plan.
-func TestAccEndpoint_labelSurvivesRemoval(t *testing.T) {
+// TestAccEndpoint_labelClearedByRemoval checks that dropping the label from the
+// configuration writes an empty label and settles into an empty plan.
+func TestAccEndpoint_labelClearedByRemoval(t *testing.T) {
unlabelled := fmt.Sprintf(`
resource "quicknode_endpoint" "test" {
chain = %q
@@ -418,7 +420,7 @@ resource "quicknode_endpoint" "test" {
},
{
Config: unlabelled,
- Check: resource.TestCheckResourceAttr("quicknode_endpoint.test", "label", "tfacc-label"),
+ Check: resource.TestCheckNoResourceAttr("quicknode_endpoint.test", "label"),
},
},
})
@@ -504,15 +506,14 @@ resource "quicknode_endpoint_token" "test" {
Check: resource.ComposeAggregateTestCheckFunc(
resource.TestCheckResourceAttrSet("quicknode_endpoint_token.test", "id"),
resource.TestCheckResourceAttrSet("quicknode_endpoint_token.test", "token"),
+ resource.TestCheckResourceAttrWith("quicknode_endpoint_token.test", "http_url_with_token", func(value string) error {
+ if strings.Contains(value, "REPLACE_WITH_TOKEN") {
+ return fmt.Errorf("http_url_with_token still carries the placeholder: %s", client.RedactEndpointURL(value))
+ }
+ return nil
+ }),
),
},
- {
- // The endpoint is created carrying one token and is not read
- // again during the apply that adds the second, so the count on
- // the endpoint only settles on the next refresh.
- Config: config,
- Check: resource.TestCheckResourceAttr("quicknode_endpoint.test", "tokens.#", "2"),
- },
{
ResourceName: "quicknode_endpoint_token.test",
ImportState: true,
@@ -670,3 +671,47 @@ resource "quicknode_endpoint_ip" "test" {
},
})
}
+
+// TestAccEndpointURLsDataSource_multichain reads the URLs route before and
+// after multichain is enabled, so both the empty maps and the populated ones
+// are covered.
+func TestAccEndpointURLsDataSource_multichain(t *testing.T) {
+ withMultichain := func(enabled bool) string {
+ return fmt.Sprintf(`
+resource "quicknode_endpoint" "test" {
+ chain = %q
+ network = %q
+ label = "tfacc-urls"
+ multichain = %t
+}
+
+data "quicknode_endpoint_urls" "test" {
+ endpoint_id = quicknode_endpoint.test.id
+ depends_on = [quicknode_endpoint.test]
+}
+`, acceptanceChain, acceptanceNetwork, enabled)
+ }
+
+ resource.Test(t, resource.TestCase{
+ PreCheck: func() { testAccPreCheck(t) },
+ ProtoV6ProviderFactories: protoV6ProviderFactories,
+ CheckDestroy: testAccCheckEndpointsDestroyed,
+ Steps: []resource.TestStep{
+ {
+ Config: withMultichain(false),
+ Check: resource.ComposeAggregateTestCheckFunc(
+ resource.TestCheckResourceAttrPair("data.quicknode_endpoint_urls.test", "safe_http_url", "quicknode_endpoint.test", "safe_http_url"),
+ resource.TestCheckResourceAttrSet("data.quicknode_endpoint_urls.test", "http_url_with_token"),
+ resource.TestCheckResourceAttr("data.quicknode_endpoint_urls.test", "safe_multichain_urls.%", "0"),
+ ),
+ },
+ {
+ Config: withMultichain(true),
+ Check: resource.ComposeAggregateTestCheckFunc(
+ resource.TestMatchResourceAttr("data.quicknode_endpoint_urls.test", "safe_multichain_urls.base-sepolia.http_url", regexp.MustCompile(`/REPLACE_WITH_TOKEN/`)),
+ resource.TestCheckResourceAttrSet("data.quicknode_endpoint_urls.test", "multichain_urls_with_token.base-sepolia.http_url"),
+ ),
+ },
+ },
+ })
+}
diff --git a/internal/provider/endpoint_data_source.go b/internal/provider/endpoint_data_source.go
index a20c8f1..b8f6cf2 100644
--- a/internal/provider/endpoint_data_source.go
+++ b/internal/provider/endpoint_data_source.go
@@ -86,11 +86,11 @@ func (d *endpointDataSource) Schema(_ context.Context, _ datasource.SchemaReques
},
"safe_http_url": schema.StringAttribute{
Computed: true,
- MarkdownDescription: "The HTTPS URL with the auth token replaced by `TOKEN`. Safe to log or display.",
+ MarkdownDescription: "The HTTPS URL with the auth token replaced by `REPLACE_WITH_TOKEN`. Safe to log or display.",
},
"safe_wss_url": schema.StringAttribute{
Computed: true,
- MarkdownDescription: "The WebSocket URL with the auth token replaced by `TOKEN`, or null on chains without WebSocket support.",
+ MarkdownDescription: "The WebSocket URL with the auth token replaced by `REPLACE_WITH_TOKEN`, or null on chains without WebSocket support.",
},
"http_url_with_token": schema.StringAttribute{
Computed: true,
@@ -194,7 +194,7 @@ func (d *endpointDataSource) Read(ctx context.Context, req datasource.ReadReques
})
}
- options, diags := securityOptionsObject(endpoint.Security)
+ options, diags := securityReportObject(endpoint.Security)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
diff --git a/internal/provider/endpoint_resource.go b/internal/provider/endpoint_resource.go
index b8878f5..cf32f89 100644
--- a/internal/provider/endpoint_resource.go
+++ b/internal/provider/endpoint_resource.go
@@ -11,7 +11,6 @@ import (
"github.com/hashicorp/terraform-plugin-framework/resource"
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/booldefault"
- "github.com/hashicorp/terraform-plugin-framework/resource/schema/listplanmodifier"
"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"
@@ -26,11 +25,6 @@ const (
statusPaused = "paused"
)
-var endpointTokenAttrTypes = map[string]attr.Type{
- "id": types.StringType,
- "token": types.StringType,
-}
-
var _ resource.Resource = (*endpointResource)(nil)
var _ resource.ResourceWithConfigure = (*endpointResource)(nil)
var _ resource.ResourceWithImportState = (*endpointResource)(nil)
@@ -42,20 +36,17 @@ type endpointResource struct {
}
type endpointResourceModel struct {
- ID types.String `tfsdk:"id"`
- Chain types.String `tfsdk:"chain"`
- Network types.String `tfsdk:"network"`
- Label types.String `tfsdk:"label"`
- Status types.String `tfsdk:"status"`
- Multichain types.Bool `tfsdk:"multichain"`
- Tags types.Set `tfsdk:"tags"`
- SafeHTTPURL types.String `tfsdk:"safe_http_url"`
- SafeWSSURL types.String `tfsdk:"safe_wss_url"`
- HTTPURLWithToken types.String `tfsdk:"http_url_with_token"`
- WSSURLWithToken types.String `tfsdk:"wss_url_with_token"`
- Tokens types.List `tfsdk:"tokens"`
- SecurityOptions types.Object `tfsdk:"security_options"`
- IPCustomHeader types.String `tfsdk:"ip_custom_header"`
+ ID types.String `tfsdk:"id"`
+ Chain types.String `tfsdk:"chain"`
+ Network types.String `tfsdk:"network"`
+ Label types.String `tfsdk:"label"`
+ Status types.String `tfsdk:"status"`
+ Multichain types.Bool `tfsdk:"multichain"`
+ Tags types.Set `tfsdk:"tags"`
+ SafeHTTPURL types.String `tfsdk:"safe_http_url"`
+ SafeWSSURL types.String `tfsdk:"safe_wss_url"`
+ SecurityOptions types.Object `tfsdk:"security_options"`
+ IPCustomHeader types.String `tfsdk:"ip_custom_header"`
}
func NewEndpointResource() resource.Resource {
@@ -69,9 +60,10 @@ func (r *endpointResource) Metadata(_ context.Context, req resource.MetadataRequ
func (r *endpointResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
resp.Schema = schema.Schema{
MarkdownDescription: "A Quicknode RPC endpoint on a chain and network.\n\n" +
- "Pass `http_url_with_token` to anything that needs to make RPC calls. " +
- "`safe_http_url` and `safe_wss_url` carry the literal `TOKEN` where the credential belongs, so they are safe to log or display " +
- "while keeping the real URL's shape, including any path suffix the chain appends.",
+ "`safe_http_url` and `safe_wss_url` carry the literal `REPLACE_WITH_TOKEN` where the credential belongs, so they are safe to log or display " +
+ "while keeping the real URL's shape, including any path suffix the chain appends.\n\n" +
+ "The resource does not track the endpoint's tokens or the URLs that carry them, because adding or removing a token changes both. " +
+ "Read a working URL from `data.quicknode_endpoint_urls`, or from the `quicknode_endpoint_token` that issued the credential.",
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{
Computed: true,
@@ -89,11 +81,9 @@ func (r *endpointResource) Schema(_ context.Context, _ resource.SchemaRequest, r
PlanModifiers: []planmodifier.String{stringplanmodifier.RequiresReplace()},
},
"label": schema.StringAttribute{
- Optional: true,
- Computed: true,
- MarkdownDescription: "Descriptive label for the endpoint. Labels are not unique and do not identify the endpoint. " +
- "Quicknode has no route for clearing a label once set, so removing the attribute leaves the current label in place and Terraform stops tracking it.",
- Validators: []validator.String{stringvalidator.LengthAtLeast(1)},
+ Optional: true,
+ MarkdownDescription: "Descriptive label for the endpoint. Labels are not unique and do not identify the endpoint. Removing the attribute clears the label.",
+ Validators: []validator.String{stringvalidator.LengthAtLeast(1)},
},
"status": schema.StringAttribute{
Optional: true,
@@ -115,24 +105,12 @@ func (r *endpointResource) Schema(_ context.Context, _ resource.SchemaRequest, r
},
"safe_http_url": schema.StringAttribute{
Computed: true,
- MarkdownDescription: "The HTTPS URL with the auth token replaced by `TOKEN`. Safe to log or display. Substitute a real token to make it usable: `replace(self.safe_http_url, \"TOKEN\", self.tokens[0].token)`.",
+ MarkdownDescription: "The HTTPS URL with the auth token replaced by `REPLACE_WITH_TOKEN`. Safe to log or display.",
PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()},
},
"safe_wss_url": schema.StringAttribute{
Computed: true,
- MarkdownDescription: "The WebSocket URL with the auth token replaced by `TOKEN`, or null on chains without WebSocket support.",
- PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()},
- },
- "http_url_with_token": schema.StringAttribute{
- Computed: true,
- Sensitive: true,
- MarkdownDescription: "The working HTTPS endpoint, exactly as the Admin API returns it. Pass this to whatever makes RPC calls.",
- PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()},
- },
- "wss_url_with_token": schema.StringAttribute{
- Computed: true,
- Sensitive: true,
- MarkdownDescription: "The working WebSocket endpoint, or null on chains without WebSocket support.",
+ MarkdownDescription: "The WebSocket URL with the auth token replaced by `REPLACE_WITH_TOKEN`, or null on chains without WebSocket support.",
PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()},
},
"security_options": securityOptionsSchema(),
@@ -141,24 +119,6 @@ func (r *endpointResource) Schema(_ context.Context, _ resource.SchemaRequest, r
MarkdownDescription: "Name of the header the endpoint reads the caller's IP address from, for example `X-Real-IP`. Set it when calls arrive through a proxy, so IP restrictions see the original caller's address and not the proxy's.",
Validators: []validator.String{stringvalidator.LengthAtLeast(1)},
},
- "tokens": schema.ListNestedAttribute{
- Computed: true,
- MarkdownDescription: "Auth tokens for the endpoint. An endpoint can carry several. Token values are stored in Terraform state, so keep state encrypted and remote.",
- PlanModifiers: []planmodifier.List{listplanmodifier.UseStateForUnknown()},
- NestedObject: schema.NestedAttributeObject{
- Attributes: map[string]schema.Attribute{
- "id": schema.StringAttribute{
- Computed: true,
- MarkdownDescription: "Token id.",
- },
- "token": schema.StringAttribute{
- Computed: true,
- Sensitive: true,
- MarkdownDescription: "Token value.",
- },
- },
- },
- },
},
}
}
@@ -218,7 +178,7 @@ func (r *endpointResource) Create(ctx context.Context, req resource.CreateReques
return
}
- if !plan.Label.IsUnknown() && !plan.Label.IsNull() {
+ if !plan.Label.IsNull() || created.Label != "" {
if err := r.client.SetEndpointLabel(ctx, created.ID, plan.Label.ValueString()); err != nil {
resp.Diagnostics.AddError(
"Created the endpoint but could not set its label",
@@ -301,7 +261,7 @@ func (r *endpointResource) Update(ctx context.Context, req resource.UpdateReques
id := state.ID.ValueString()
plan.ID = state.ID
- if !plan.Label.IsUnknown() && !plan.Label.Equal(state.Label) {
+ if !plan.Label.Equal(state.Label) {
if err := r.client.SetEndpointLabel(ctx, id, plan.Label.ValueString()); err != nil {
resp.Diagnostics.AddError("Could not update the endpoint label", err.Error())
return
@@ -369,15 +329,8 @@ func (r *endpointResource) readInto(ctx context.Context, id string, model *endpo
return
}
model.ID = types.StringValue(endpoint.ID)
- if model.Label.IsUnknown() {
- model.Label = stringOrNull(endpoint.Label)
- }
applyEndpointURLs(endpoint, model)
- tokens, tokenDiags := tokenList(endpoint.Tokens)
- diags.Append(tokenDiags...)
- model.Tokens = tokens
-
options, optionDiags := securityOptionsObject(endpoint.Security)
diags.Append(optionDiags...)
model.SecurityOptions = options
@@ -419,20 +372,11 @@ func applyEndpoint(endpoint *client.Endpoint, state *endpointResourceModel) diag
state.Multichain = types.BoolValue(endpoint.Multichain)
applyEndpointURLs(endpoint, state)
- tokens, tokenDiags := tokenList(endpoint.Tokens)
- diags.Append(tokenDiags...)
- state.Tokens = tokens
-
options, optionDiags := securityOptionsObject(endpoint.Security)
diags.Append(optionDiags...)
state.SecurityOptions = options
state.IPCustomHeader = stringOrNull(endpoint.Security.IPCustomHeader)
-
- if endpoint.Label == "" {
- state.Label = types.StringNull()
- } else {
- state.Label = types.StringValue(endpoint.Label)
- }
+ state.Label = stringOrNull(endpoint.Label)
if len(endpoint.Tags) == 0 && state.Tags.IsNull() {
return diags
@@ -452,8 +396,6 @@ func applyEndpoint(endpoint *client.Endpoint, state *endpointResourceModel) diag
func applyEndpointURLs(endpoint *client.Endpoint, model *endpointResourceModel) {
model.SafeHTTPURL = stringOrNull(endpoint.SafeHTTPURL)
model.SafeWSSURL = stringOrNull(endpoint.SafeWSSURL)
- model.HTTPURLWithToken = stringOrNull(endpoint.HTTPURLWithToken)
- model.WSSURLWithToken = stringOrNull(endpoint.WSSURLWithToken)
}
func stringOrNull(value string) types.String {
@@ -463,28 +405,6 @@ func stringOrNull(value string) types.String {
return types.StringValue(value)
}
-func tokenList(tokens []client.EndpointToken) (types.List, diag.Diagnostics) {
- elementType := types.ObjectType{AttrTypes: endpointTokenAttrTypes}
- values := make([]attr.Value, 0, len(tokens))
- var diags diag.Diagnostics
-
- for _, token := range tokens {
- value, objectDiags := types.ObjectValue(endpointTokenAttrTypes, map[string]attr.Value{
- "id": types.StringValue(token.ID),
- "token": types.StringValue(token.Value),
- })
- diags.Append(objectDiags...)
- values = append(values, value)
- }
- if diags.HasError() {
- return types.ListNull(elementType), diags
- }
-
- list, listDiags := types.ListValue(elementType, values)
- diags.Append(listDiags...)
- return list, diags
-}
-
func (r *endpointResource) reconcileTags(ctx context.Context, id string, planned types.Set) diag.Diagnostics {
var diags diag.Diagnostics
diff --git a/internal/provider/endpoint_urls_data_source.go b/internal/provider/endpoint_urls_data_source.go
new file mode 100644
index 0000000..7c1792d
--- /dev/null
+++ b/internal/provider/endpoint_urls_data_source.go
@@ -0,0 +1,137 @@
+package provider
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/hashicorp/terraform-plugin-framework/datasource"
+ "github.com/hashicorp/terraform-plugin-framework/datasource/schema"
+ "github.com/hashicorp/terraform-plugin-framework/types"
+
+ "github.com/quicknode/terraform-provider-quicknode/internal/client"
+)
+
+var _ datasource.DataSource = (*endpointURLsDataSource)(nil)
+var _ datasource.DataSourceWithConfigure = (*endpointURLsDataSource)(nil)
+
+type endpointURLsDataSource struct {
+ client *client.Client
+}
+
+type endpointURLsDataSourceModel struct {
+ EndpointID types.String `tfsdk:"endpoint_id"`
+ SafeHTTPURL types.String `tfsdk:"safe_http_url"`
+ SafeWSSURL types.String `tfsdk:"safe_wss_url"`
+ HTTPURLWithToken types.String `tfsdk:"http_url_with_token"`
+ WSSURLWithToken types.String `tfsdk:"wss_url_with_token"`
+ SafeMultichainURLs map[string]networkURLsModel `tfsdk:"safe_multichain_urls"`
+ MultichainURLsWithToken map[string]networkURLsModel `tfsdk:"multichain_urls_with_token"`
+}
+
+type networkURLsModel struct {
+ HTTPURL types.String `tfsdk:"http_url"`
+ WSSURL types.String `tfsdk:"wss_url"`
+}
+
+func NewEndpointURLsDataSource() datasource.DataSource {
+ return &endpointURLsDataSource{}
+}
+
+func (d *endpointURLsDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) {
+ resp.TypeName = req.ProviderTypeName + "_endpoint_urls"
+}
+
+func (d *endpointURLsDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) {
+ networkURLs := func(description string, sensitive bool) schema.MapNestedAttribute {
+ return schema.MapNestedAttribute{
+ Computed: true,
+ Sensitive: sensitive,
+ MarkdownDescription: description,
+ NestedObject: schema.NestedAttributeObject{
+ Attributes: map[string]schema.Attribute{
+ "http_url": schema.StringAttribute{Computed: true, MarkdownDescription: "The network's HTTPS URL."},
+ "wss_url": schema.StringAttribute{Computed: true, MarkdownDescription: "The network's WebSocket URL, or null on networks without WebSocket support."},
+ },
+ },
+ }
+ }
+ resp.Schema = schema.Schema{
+ MarkdownDescription: "The URLs an endpoint serves, read fresh on every plan. The credentialed URLs carry whichever token the Admin API currently embeds, " +
+ "which changes when tokens are added or removed, so they are read here and not tracked on `quicknode_endpoint`.\n\n" +
+ "A multichain endpoint also serves every network in `safe_multichain_urls` and `multichain_urls_with_token`, keyed by network slug. " +
+ "Both maps are empty when `multichain` is off.\n\n" +
+ "Set `depends_on` to the endpoint, or to the `quicknode_endpoint_token` resources on it, when they are in the same configuration. " +
+ "The id is known before the apply, so without it the URLs are read during the plan, before a change to `multichain` or to the tokens is applied.",
+ Attributes: map[string]schema.Attribute{
+ "endpoint_id": schema.StringAttribute{
+ Required: true,
+ MarkdownDescription: "Endpoint id.",
+ },
+ "safe_http_url": schema.StringAttribute{
+ Computed: true,
+ MarkdownDescription: "The HTTPS URL with the auth token replaced by `REPLACE_WITH_TOKEN`. Safe to log or display.",
+ },
+ "safe_wss_url": schema.StringAttribute{
+ Computed: true,
+ MarkdownDescription: "The WebSocket URL with the auth token replaced by `REPLACE_WITH_TOKEN`, or null on chains without WebSocket support.",
+ },
+ "http_url_with_token": schema.StringAttribute{
+ Computed: true,
+ Sensitive: true,
+ MarkdownDescription: "The working HTTPS endpoint, exactly as the Admin API returns it.",
+ },
+ "wss_url_with_token": schema.StringAttribute{
+ Computed: true,
+ Sensitive: true,
+ MarkdownDescription: "The working WebSocket endpoint, or null on chains without WebSocket support.",
+ },
+ "safe_multichain_urls": networkURLs("Every network a multichain endpoint serves, with the auth token replaced by `REPLACE_WITH_TOKEN`. Safe to log or display.", false),
+ "multichain_urls_with_token": networkURLs("Every network a multichain endpoint serves, with working URLs.", true),
+ },
+ }
+}
+
+func (d *endpointURLsDataSource) Configure(_ context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) {
+ if req.ProviderData == nil {
+ return
+ }
+ data, ok := req.ProviderData.(providerData)
+ if !ok {
+ resp.Diagnostics.AddError("Unexpected provider data", fmt.Sprintf("The endpoint URLs data source expected providerData, got %T.", req.ProviderData))
+ return
+ }
+ d.client = data.Client
+}
+
+func (d *endpointURLsDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) {
+ var config endpointURLsDataSourceModel
+ resp.Diagnostics.Append(req.Config.Get(ctx, &config)...)
+ if resp.Diagnostics.HasError() {
+ return
+ }
+
+ urls, err := d.client.GetEndpointURLs(ctx, config.EndpointID.ValueString())
+ if err != nil {
+ resp.Diagnostics.AddError("Could not read the endpoint's URLs", err.Error())
+ return
+ }
+
+ config.SafeHTTPURL = stringOrNull(urls.SafeHTTPURL)
+ config.SafeWSSURL = stringOrNull(urls.SafeWSSURL)
+ config.HTTPURLWithToken = stringOrNull(urls.HTTPURLWithToken)
+ config.WSSURLWithToken = stringOrNull(urls.WSSURLWithToken)
+
+ config.SafeMultichainURLs = make(map[string]networkURLsModel, len(urls.Multichain))
+ config.MultichainURLsWithToken = make(map[string]networkURLsModel, len(urls.Multichain))
+ for network, networkURLs := range urls.Multichain {
+ config.SafeMultichainURLs[network] = networkURLsModel{
+ HTTPURL: stringOrNull(networkURLs.SafeHTTPURL),
+ WSSURL: stringOrNull(networkURLs.SafeWSSURL),
+ }
+ config.MultichainURLsWithToken[network] = networkURLsModel{
+ HTTPURL: stringOrNull(networkURLs.HTTPURLWithToken),
+ WSSURL: stringOrNull(networkURLs.WSSURLWithToken),
+ }
+ }
+ resp.Diagnostics.Append(resp.State.Set(ctx, &config)...)
+}
diff --git a/internal/provider/endpoints_data_source.go b/internal/provider/endpoints_data_source.go
index 1545b22..62268fb 100644
--- a/internal/provider/endpoints_data_source.go
+++ b/internal/provider/endpoints_data_source.go
@@ -90,11 +90,11 @@ func (d *endpointsDataSource) Schema(_ context.Context, _ datasource.SchemaReque
"status": schema.StringAttribute{Computed: true, MarkdownDescription: "`active` or `paused`."},
"safe_http_url": schema.StringAttribute{
Computed: true,
- MarkdownDescription: "The HTTPS URL with the auth token replaced by `TOKEN`. Safe to log or display. The list route carries no usable token, so read `data.quicknode_endpoint` for a working URL.",
+ MarkdownDescription: "The HTTPS URL with the auth token replaced by `REPLACE_WITH_TOKEN`. Safe to log or display. The list route carries no usable token, so read `data.quicknode_endpoint` for a working URL.",
},
"safe_wss_url": schema.StringAttribute{
Computed: true,
- MarkdownDescription: "The WebSocket URL with the auth token replaced by `TOKEN`, or null on chains without WebSocket support.",
+ MarkdownDescription: "The WebSocket URL with the auth token replaced by `REPLACE_WITH_TOKEN`, or null on chains without WebSocket support.",
},
"dedicated": schema.BoolAttribute{Computed: true, MarkdownDescription: "Whether the endpoint runs on dedicated infrastructure."},
"flat_rate": schema.BoolAttribute{Computed: true, MarkdownDescription: "Whether the endpoint is billed at a flat rate."},
diff --git a/internal/provider/provider.go b/internal/provider/provider.go
index 5463d73..0f8963a 100644
--- a/internal/provider/provider.go
+++ b/internal/provider/provider.go
@@ -146,5 +146,6 @@ func (p *quicknodeProvider) DataSources(_ context.Context) []func() datasource.D
NewChainsDataSource,
NewEndpointDataSource,
NewEndpointsDataSource,
+ NewEndpointURLsDataSource,
}
}
diff --git a/internal/provider/provider_test.go b/internal/provider/provider_test.go
index 98d95a1..ff7bd82 100644
--- a/internal/provider/provider_test.go
+++ b/internal/provider/provider_test.go
@@ -38,7 +38,7 @@ func TestProviderSchema(t *testing.T) {
t.Errorf("%s is missing, got %v", name, keys(schema.ResourceSchemas))
}
}
- for _, name := range []string{"quicknode_chains", "quicknode_endpoint", "quicknode_endpoints"} {
+ for _, name := range []string{"quicknode_chains", "quicknode_endpoint", "quicknode_endpoints", "quicknode_endpoint_urls"} {
if _, ok := schema.DataSourceSchemas[name]; !ok {
t.Errorf("%s is missing, got %v", name, keys(schema.DataSourceSchemas))
}
diff --git a/internal/provider/request_filter_resource.go b/internal/provider/request_filter_resource.go
index 209f4d4..619415b 100644
--- a/internal/provider/request_filter_resource.go
+++ b/internal/provider/request_filter_resource.go
@@ -43,7 +43,7 @@ func (r *requestFilterResource) Metadata(_ context.Context, req resource.Metadat
func (r *requestFilterResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
resp.Schema = schema.Schema{
MarkdownDescription: "The set of RPC methods a Quicknode endpoint accepts. Anything outside the set is rejected, which keeps an endpoint handed to a browser or a third party from reaching methods it has no reason to call.\n\n" +
- "`security_options.request_filters` on the endpoint reports whether filtering is applied. It is read-only: the Admin API turns it on when a filter exists and off when the last one is removed.",
+ "The Admin API turns filtering on when a filter exists and off when the last one is removed. `data.quicknode_endpoint` reports it as `security_options.request_filters`.",
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{
Computed: true,
diff --git a/internal/provider/security_options.go b/internal/provider/security_options.go
index c009d9f..8f47c54 100644
--- a/internal/provider/security_options.go
+++ b/internal/provider/security_options.go
@@ -21,10 +21,20 @@ var securityOptionsAttrTypes = map[string]attr.Type{
"domain_masks": types.BoolType,
"hsts": types.BoolType,
"cors": types.BoolType,
- "request_filters": types.BoolType,
"response_logging": types.BoolType,
}
+// securityReportAttrTypes adds request_filters, which only the data sources
+// report. The Admin API sets it when a quicknode_endpoint_request_filter
+// exists, so on the endpoint resource it would go stale whenever one is created.
+var securityReportAttrTypes = func() map[string]attr.Type {
+ attrTypes := map[string]attr.Type{"request_filters": types.BoolType}
+ for name, attrType := range securityOptionsAttrTypes {
+ attrTypes[name] = attrType
+ }
+ return attrTypes
+}()
+
type securityOptionsModel struct {
Tokens types.Bool `tfsdk:"tokens"`
Referrers types.Bool `tfsdk:"referrers"`
@@ -34,7 +44,6 @@ type securityOptionsModel struct {
HSTS types.Bool `tfsdk:"hsts"`
Cors types.Bool `tfsdk:"cors"`
- RequestFilters types.Bool `tfsdk:"request_filters"`
ResponseLogging types.Bool `tfsdk:"response_logging"`
}
@@ -60,10 +69,6 @@ func securityOptionsSchema() schema.SingleNestedAttribute {
"domain_masks": settable("Serve the endpoint from an approved custom domain. Add them with `quicknode_endpoint_domain_mask`."),
"hsts": settable("Send the HTTP Strict Transport Security header."),
"cors": settable("Apply Cross-Origin Resource Sharing policy. New endpoints have this enabled."),
- "request_filters": schema.BoolAttribute{
- Computed: true,
- MarkdownDescription: "Whether RPC method filtering is applied. Read-only: the Admin API turns this on when a `quicknode_endpoint_request_filter` exists and off when the last one is removed.",
- },
"response_logging": schema.BoolAttribute{
Computed: true,
MarkdownDescription: "Whether responses are logged for the endpoint. Read-only: the account's plan sets it and it cannot be changed per endpoint.",
@@ -72,10 +77,21 @@ func securityOptionsSchema() schema.SingleNestedAttribute {
}
}
-// securityOptionsObject renders what the API reports. Every toggle is known
-// after a read, including the two the provider cannot write.
+// securityOptionsObject renders what the API reports for the endpoint
+// resource. Every toggle is known after a read, including response_logging,
+// which the provider cannot write.
func securityOptionsObject(options client.SecurityOptions) (types.Object, diag.Diagnostics) {
- return types.ObjectValue(securityOptionsAttrTypes, map[string]attr.Value{
+ return types.ObjectValue(securityOptionsAttrTypes, securityOptionValues(options))
+}
+
+func securityReportObject(options client.SecurityOptions) (types.Object, diag.Diagnostics) {
+ values := securityOptionValues(options)
+ values["request_filters"] = types.BoolValue(options.RequestFilters)
+ return types.ObjectValue(securityReportAttrTypes, values)
+}
+
+func securityOptionValues(options client.SecurityOptions) map[string]attr.Value {
+ return map[string]attr.Value{
"tokens": types.BoolValue(options.Tokens),
"referrers": types.BoolValue(options.Referrers),
"jwts": types.BoolValue(options.JWTs),
@@ -83,9 +99,8 @@ func securityOptionsObject(options client.SecurityOptions) (types.Object, diag.D
"domain_masks": types.BoolValue(options.DomainMasks),
"hsts": types.BoolValue(options.HSTS),
"cors": types.BoolValue(options.Cors),
- "request_filters": types.BoolValue(options.RequestFilters),
"response_logging": types.BoolValue(options.ResponseLogging),
- })
+ }
}
// securityOptionsPatch collects the toggles worth writing. An unknown value is
diff --git a/internal/provider/token_resource.go b/internal/provider/token_resource.go
index 068590f..480bdb1 100644
--- a/internal/provider/token_resource.go
+++ b/internal/provider/token_resource.go
@@ -5,6 +5,7 @@ import (
"fmt"
"strings"
+ "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/resource/schema/planmodifier"
@@ -23,9 +24,11 @@ type endpointTokenResource struct {
}
type endpointTokenResourceModel struct {
- ID types.String `tfsdk:"id"`
- EndpointID types.String `tfsdk:"endpoint_id"`
- Token types.String `tfsdk:"token"`
+ ID types.String `tfsdk:"id"`
+ EndpointID types.String `tfsdk:"endpoint_id"`
+ Token types.String `tfsdk:"token"`
+ HTTPURLWithToken types.String `tfsdk:"http_url_with_token"`
+ WSSURLWithToken types.String `tfsdk:"wss_url_with_token"`
}
func NewEndpointTokenResource() resource.Resource {
@@ -57,6 +60,18 @@ func (r *endpointTokenResource) Schema(_ context.Context, _ resource.SchemaReque
MarkdownDescription: "The token value. It is stored in Terraform state, so keep state encrypted and remote.",
PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()},
},
+ "http_url_with_token": schema.StringAttribute{
+ Computed: true,
+ Sensitive: true,
+ MarkdownDescription: "The endpoint's HTTPS URL carrying this token. Pass it to the consumer the token was issued for.",
+ PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()},
+ },
+ "wss_url_with_token": schema.StringAttribute{
+ Computed: true,
+ Sensitive: true,
+ MarkdownDescription: "The endpoint's WebSocket URL carrying this token, or null on chains without WebSocket support.",
+ PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()},
+ },
},
}
}
@@ -88,6 +103,7 @@ func (r *endpointTokenResource) Create(ctx context.Context, req resource.CreateR
plan.ID = types.StringValue(created.ID)
plan.Token = types.StringValue(created.Value)
+ resp.Diagnostics.Append(r.applyURLs(ctx, &plan)...)
resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...)
resp.Diagnostics.Append(warnToggleDisabled(ctx, r.client, plan.EndpointID.ValueString(), "tokens", "Token authentication")...)
}
@@ -114,6 +130,9 @@ func (r *endpointTokenResource) Read(ctx context.Context, req resource.ReadReque
continue
}
state.Token = types.StringValue(token.Value)
+ if state.HTTPURLWithToken.IsNull() {
+ resp.Diagnostics.Append(r.applyURLs(ctx, &state)...)
+ }
resp.Diagnostics.Append(resp.State.Set(ctx, &state)...)
return
}
@@ -169,8 +188,25 @@ func (r *endpointTokenResource) ImportState(ctx context.Context, req resource.Im
EndpointID: types.StringValue(endpointID),
Token: types.StringValue(token.Value),
}
+ resp.Diagnostics.Append(r.applyURLs(ctx, &state)...)
resp.Diagnostics.Append(resp.State.Set(ctx, &state)...)
return
}
resp.Diagnostics.AddError("No matching token", fmt.Sprintf("Endpoint %s has no token with the id %q.", endpointID, tokenID))
}
+
+// applyURLs builds the token's URLs from the endpoint's redacted ones, so they
+// carry this token whichever one the Admin API embeds in its own URLs.
+func (r *endpointTokenResource) applyURLs(ctx context.Context, model *endpointTokenResourceModel) diag.Diagnostics {
+ var diags diag.Diagnostics
+
+ endpoint, err := r.client.GetEndpoint(ctx, model.EndpointID.ValueString())
+ if err != nil {
+ diags.AddError("Could not read the endpoint's URLs", err.Error())
+ return diags
+ }
+ token := model.Token.ValueString()
+ model.HTTPURLWithToken = stringOrNull(client.EndpointURLWithToken(endpoint.SafeHTTPURL, token))
+ model.WSSURLWithToken = stringOrNull(client.EndpointURLWithToken(endpoint.SafeWSSURL, token))
+ return diags
+}
diff --git a/templates/index.md.tmpl b/templates/index.md.tmpl
index 92a334c..f328e23 100644
--- a/templates/index.md.tmpl
+++ b/templates/index.md.tmpl
@@ -25,7 +25,7 @@ export QUICKNODE_API_KEY="your-api-key"
## What you can manage
-| | |
+| Resource | Manages |
|---|---|
| `quicknode_endpoint` | the endpoint itself, its label, status, tags and which security mechanisms it enforces |
| `quicknode_endpoint_ip`, `_domain_mask`, `_referrer` | who is allowed to call it |
@@ -34,6 +34,7 @@ export QUICKNODE_API_KEY="your-api-key"
| `quicknode_endpoint_request_filter` | which RPC methods it accepts |
| `quicknode_endpoint_rate_limits`, `_method_rate_limit` | how much traffic it accepts, overall and per method |
| `data.quicknode_endpoint`, `data.quicknode_endpoints` | endpoints created elsewhere |
+| `data.quicknode_endpoint_urls` | an endpoint's working URLs, including every network of a multichain endpoint |
| `data.quicknode_chains` | every chain and network slug, for validating configuration at plan time |
A security mechanism is enabled on the endpoint and the entries it applies to
@@ -43,24 +44,29 @@ than deleted on the next apply.
## Endpoint URLs
The Admin API returns endpoint URLs with the auth token embedded in the path.
-Endpoints expose both forms:
+The provider exposes two forms:
-| Attribute | Sensitive | Use it for |
-|---|---|---|
-| `http_url_with_token`, `wss_url_with_token` | yes | anything that makes RPC calls |
-| `safe_http_url`, `safe_wss_url` | no | logging, display, anything that must not hold a credential |
+| Attribute | Sensitive | Where | Use it for |
+|---|---|---|---|
+| `http_url_with_token`, `wss_url_with_token` | yes | `data.quicknode_endpoint_urls`, `quicknode_endpoint_token` | anything that makes RPC calls |
+| `safe_http_url`, `safe_wss_url` | no | `quicknode_endpoint` and the data sources | logging, display, anything that must not hold a credential |
-The safe form carries the literal `TOKEN` where the credential belongs:
+The URL the Admin API returns carries one of the endpoint's tokens, and which
+one changes as tokens are added and removed. So `quicknode_endpoint` only keeps
+the safe form, and the working URLs are read from `data.quicknode_endpoint_urls`
+or taken from the `quicknode_endpoint_token` that issued the credential.
+
+The safe form carries the literal `REPLACE_WITH_TOKEN` where the credential belongs:
```
-https://example-name.hype-testnet.quiknode.pro/TOKEN/evm
+https://example-name.hype-testnet.quiknode.pro/REPLACE_WITH_TOKEN/evm
```
It keeps the real URL's shape, so substituting a token reproduces a working
address on every chain:
```hcl
-replace(quicknode_endpoint.api.safe_http_url, "TOKEN", var.token)
+replace(quicknode_endpoint.api.safe_http_url, "REPLACE_WITH_TOKEN", var.token)
```
Do not assemble a URL from parts instead. The token is not always the last path