diff --git a/crates/fmp-py/README.md b/crates/fmp-py/README.md index b82e946..1a2d9ec 100644 --- a/crates/fmp-py/README.md +++ b/crates/fmp-py/README.md @@ -23,7 +23,9 @@ print(rows[0].symbol, rows[0].price) omitted (unset, empty, or whitespace-only counts as absent); an explicit `token=...` always wins, and `auth_mode="none"` ignores the variable. With neither a token nor the variable, the default host raises `FmpConfigError` -naming `FMP_API_KEY`, while a custom `base_url` selects no auth. +naming `FMP_API_KEY`, while a custom `base_url` selects no auth. When +`base_url` is omitted, `FMP_BASE_URL` is read the same way (see +[Using a proxy](#using-a-proxy)). `FmpClient` is synchronous: each call releases the Python GIL while the async Rust transport waits, so threads keep running. A Python async facade is not @@ -193,6 +195,39 @@ A client that is never closed keeps its pooled connections until the next request made through any client after it and its namespaces are garbage-collected, or until the process exits. +## Using a proxy + +A proxy that issues its own keys and forwards to FMP, such as +[valet](https://valet.bitbrew.app), needs two environment variables and no +code change: + +```sh +export FMP_API_KEY=vk_... +export FMP_BASE_URL=https://valet.bitbrew.app/fmp +``` + +```python +from fmp import FmpClient + +client = FmpClient() +rows = client.quote.short("AAPL") +``` + +The key goes in the `apikey` header and the base URL keeps its path, so the +quote endpoint resolves to `https://valet.bitbrew.app/fmp/stable/quote`. +The rules: + +| Setting | Result | +| --- | --- | +| `base_url=...` given | used as is; `FMP_BASE_URL` is ignored | +| `base_url` omitted, `FMP_BASE_URL` set | the variable (trimmed) is the base URL | +| neither (or a blank variable) | `https://financialmodelingprep.com` | +| `auth_mode="none"` | no credential is sent, whichever base URL applies | +| plaintext `http://` to a non-loopback host with a key | `FmpConfigError` unless `danger_allow_insecure_authentication=True` | + +`FMP_BASE_URL` only replaces the base URL; the `token` and `FMP_API_KEY` +rules above are unchanged. + ## Custom router or proxy ```python diff --git a/crates/fmp-py/python/fmp/_native/__init__.pyi b/crates/fmp-py/python/fmp/_native/__init__.pyi index 2cf3846..b9e6ebd 100644 --- a/crates/fmp-py/python/fmp/_native/__init__.pyi +++ b/crates/fmp-py/python/fmp/_native/__init__.pyi @@ -171,6 +171,14 @@ class FmpClient: both selects no auth, which is valid only with a custom base URL: against the default host the constructor raises `FmpConfigError` naming `FMP_API_KEY`. + When `base_url` is omitted, the `FMP_BASE_URL` environment variable is + read the same way, so a proxy such as valet needs only `FMP_API_KEY=vk_...` + and `FMP_BASE_URL=https://valet.bitbrew.app/fmp`: the key goes in the + `apikey` header and the quote endpoint resolves to + `https://valet.bitbrew.app/fmp/stable/quote`. An explicit `base_url` + always wins, and without either the client targets + `https://financialmodelingprep.com`. The variable only replaces the base + URL; it never selects or relaxes authentication. `timeout` and `connect_timeout` are positive finite numbers of seconds. When `timeout` is omitted, `client.bulk` methods may take up to 600 seconds and every other method up to 30; a given `timeout` applies to diff --git a/crates/fmp-py/src/client.rs b/crates/fmp-py/src/client.rs index f24f10e..c72bfe1 100644 --- a/crates/fmp-py/src/client.rs +++ b/crates/fmp-py/src/client.rs @@ -6,7 +6,7 @@ use std::{sync::Arc, time::Duration}; use libfmp::{ ClientBuilder, - config::{Authentication, RedirectPolicy, fmp_api_key_from_env}, + config::{Authentication, RedirectPolicy, fmp_api_key_from_env, fmp_base_url_from_env}, error::ConfigurationErrorKind, }; use pyo3::{ @@ -39,6 +39,12 @@ fn resolve_token(mode: Option<&str>, token: Option) -> Option { } } +/// Resolves the base URL: an explicit `base_url` always wins, otherwise +/// `FMP_BASE_URL` is used when set, and `None` keeps the default FMP origin. +fn resolve_base_url(base_url: Option) -> Option { + base_url.or_else(fmp_base_url_from_env) +} + /// Names `FMP_API_KEY` when the default host was left without a credential /// and the caller did not select `auth_mode="none"` explicitly. fn build_error(auth_mode: Option<&str>, error: libfmp::Error) -> PyErr { @@ -170,6 +176,14 @@ fn positive_duration(value: f64, field: &'static str) -> PyResult { /// both selects no auth, which is valid only with a custom base URL: against /// the default host the constructor raises `FmpConfigError` naming /// `FMP_API_KEY`. +/// When `base_url` is omitted, the `FMP_BASE_URL` environment variable is +/// read the same way, so a proxy such as valet needs only `FMP_API_KEY=vk_...` +/// and `FMP_BASE_URL=https://valet.bitbrew.app/fmp`: the key goes in the +/// `apikey` header and the quote endpoint resolves to +/// `https://valet.bitbrew.app/fmp/stable/quote`. An explicit `base_url` +/// always wins, and without either the client targets +/// `https://financialmodelingprep.com`. The variable only replaces the base +/// URL; it never selects or relaxes authentication. /// `timeout` and `connect_timeout` are positive finite numbers of seconds. /// When `timeout` is omitted, `client.bulk` methods may take up to 600 /// seconds and every other method up to 30; a given `timeout` applies to @@ -216,7 +230,7 @@ impl FmpClient { let auth = authentication(auth_mode, token, auth_name, auth_prefix)?; let mut builder = ClientBuilder::default().authentication(auth); - if let Some(base_url) = base_url { + if let Some(base_url) = resolve_base_url(base_url) { builder = builder.base_url(base_url); } if let Some(path_prefix) = path_prefix { diff --git a/crates/fmp-py/tests/conftest.py b/crates/fmp-py/tests/conftest.py index 24bb161..eb055c8 100644 --- a/crates/fmp-py/tests/conftest.py +++ b/crates/fmp-py/tests/conftest.py @@ -33,9 +33,10 @@ def test_profile_decodes_documented_fixture(client, fixture_server): server with ``path_prefix=""`` and ``auth_mode="none"``; build your own ``FmpClient(base_url=fixture_server.base_url, ...)`` for other configurations. -``FMP_API_KEY`` is removed from the environment for every test (the autouse -``isolated_api_key_env`` fixture), so a key exported in the developer's shell -cannot change which precedence row a constructor call lands on. Tests that +``FMP_API_KEY`` and ``FMP_BASE_URL`` are removed from the environment for +every test (the autouse ``isolated_api_key_env`` fixture), so values exported +in the developer's shell cannot change which precedence row a constructor call +lands on. Tests that exercise the pickup call ``monkeypatch.setenv`` themselves. """ @@ -217,8 +218,9 @@ def log_message(self, _format: str, *_args: Any) -> None: @pytest.fixture(autouse=True) def isolated_api_key_env(monkeypatch: pytest.MonkeyPatch) -> None: - """Unset ``FMP_API_KEY`` so the developer's shell cannot leak into the suite.""" + """Unset ``FMP_API_KEY`` and ``FMP_BASE_URL`` so the developer's shell cannot leak into the suite.""" monkeypatch.delenv("FMP_API_KEY", raising=False) + monkeypatch.delenv("FMP_BASE_URL", raising=False) @pytest.fixture diff --git a/crates/fmp-py/tests/test_base_url_env.py b/crates/fmp-py/tests/test_base_url_env.py new file mode 100644 index 0000000..a4008d8 --- /dev/null +++ b/crates/fmp-py/tests/test_base_url_env.py @@ -0,0 +1,93 @@ +"""``FMP_BASE_URL`` pickup when ``FmpClient`` is built without ``base_url``. + +Precedence under test: an explicit ``base_url`` always wins; otherwise the +variable replaces the default host and keeps its path, so a proxy such as +valet needs only ``FMP_API_KEY`` and ``FMP_BASE_URL``. The variable never +changes authentication: the key rules of ``test_auth_env.py`` and the +plaintext-HTTP guard still apply. +""" + +from types import SimpleNamespace + +import pytest +from conftest import FixtureServer, load_fixture +from fmp import FmpClient + +MISSING_TOKEN_MESSAGE = "no token given and FMP_API_KEY is not set" +INSECURE_MESSAGE = "authenticated plaintext HTTP requires an explicit dangerous opt-in" + + +def test_env_base_and_env_key_reach_the_proxy_path_with_the_apikey_header( + fixture_server: FixtureServer, monkeypatch: pytest.MonkeyPatch +) -> None: + """A valet-shaped base keeps its path before ``stable`` and the key rides in ``apikey``.""" + fixture_server.route("/fmp/stable/quote-short", load_fixture("quote_short.json")) + monkeypatch.setenv("FMP_API_KEY", "vk_example") + monkeypatch.setenv("FMP_BASE_URL", f" {fixture_server.base_url}/fmp\n") + assert len(FmpClient().quote.short("AAPL")) == 1 + request = fixture_server.requests[0] + assert request.path == "/fmp/stable/quote-short" + assert request.headers["apikey"] == "vk_example" + assert "apikey" not in request.query + + +def test_explicit_base_url_beats_the_env_base(fixture_server: FixtureServer, monkeypatch: pytest.MonkeyPatch) -> None: + """A ``base_url`` argument is used even when the variable points elsewhere.""" + fixture_server.route("/quote-short", load_fixture("quote_short.json")) + monkeypatch.setenv("FMP_BASE_URL", "https://unused.example/fmp") + client = FmpClient(base_url=fixture_server.base_url, path_prefix="", auth_mode="none") + assert len(client.quote.short("AAPL")) == 1 + assert fixture_server.requests[0].path == "/quote-short" + + +def test_auth_mode_none_with_the_env_base_sends_no_key( + fixture_server: FixtureServer, monkeypatch: pytest.MonkeyPatch +) -> None: + """``auth_mode="none"`` still ignores ``FMP_API_KEY`` when the base comes from the variable.""" + fixture_server.route("/stable/quote-short", load_fixture("quote_short.json")) + monkeypatch.setenv("FMP_API_KEY", "vk_example") + monkeypatch.setenv("FMP_BASE_URL", fixture_server.base_url) + assert len(FmpClient(auth_mode="none").quote.short("AAPL")) == 1 + request = fixture_server.requests[0] + assert "apikey" not in request.headers + assert "apikey" not in request.query + + +@pytest.mark.parametrize("value", [None, "", " \t\n"], ids=["unset", "empty", "blank"]) +def test_unset_or_blank_env_base_keeps_the_default_host( + errors: SimpleNamespace, monkeypatch: pytest.MonkeyPatch, value: str | None +) -> None: + """Without a usable variable the client targets the default host, which needs a key.""" + if value is not None: + monkeypatch.setenv("FMP_BASE_URL", value) + with pytest.raises(errors.FmpConfigError) as raised: + FmpClient() + assert str(raised.value) == MISSING_TOKEN_MESSAGE + + +def test_plaintext_env_base_with_a_key_needs_the_danger_flag( + errors: SimpleNamespace, monkeypatch: pytest.MonkeyPatch +) -> None: + """A non-loopback ``http://`` base from the variable keeps the insecure-auth guard.""" + monkeypatch.setenv("FMP_API_KEY", "vk_example") + monkeypatch.setenv("FMP_BASE_URL", "http://example.test") + with pytest.raises(errors.FmpConfigError) as raised: + FmpClient() + assert str(raised.value) == INSECURE_MESSAGE + assert "vk_example" not in repr(raised.value) + FmpClient(danger_allow_insecure_authentication=True) + + +@pytest.mark.parametrize( + "value", ["valet.bitbrew.app/fmp", "ftp://valet.bitbrew.app/fmp", "https://user:pw@valet.bitbrew.app/fmp"] +) +def test_invalid_env_base_raises_a_config_error( + errors: SimpleNamespace, monkeypatch: pytest.MonkeyPatch, value: str +) -> None: + """An unusable variable fails construction without echoing the value.""" + monkeypatch.setenv("FMP_API_KEY", "vk_example") + monkeypatch.setenv("FMP_BASE_URL", value) + with pytest.raises(errors.FmpConfigError) as raised: + FmpClient() + assert raised.value.category == "configuration" + assert value not in str(raised.value) diff --git a/crates/libfmp/README.md b/crates/libfmp/README.md index c62fe6a..5abe590 100644 --- a/crates/libfmp/README.md +++ b/crates/libfmp/README.md @@ -35,6 +35,38 @@ empty, or whitespace-only), so the `env::var` line above is the explicit form. The documented bare JSON array remains a `Vec`; empty and multi-row responses keep their original shape. +## Using a proxy + +A proxy that issues its own keys and forwards to FMP, such as +[valet](https://valet.bitbrew.app), needs two environment variables and no +code change: + +```sh +export FMP_API_KEY=vk_... +export FMP_BASE_URL=https://valet.bitbrew.app/fmp +``` + +```rust +use libfmp::{Client, types::Ticker}; + +# async fn example() -> Result<(), Box> { +let client = Client::from_env()?; +let rows = client.quote_short(Ticker::new("AAPL")?).await?; +# let _ = rows; +# Ok(()) +# } +``` + +`Client::from_env()` (or `ClientBuilder::from_env()?` to keep configuring) +sends the key in the `apikey` header and keeps the base URL's path, so the +quote endpoint goes to `https://valet.bitbrew.app/fmp/stable/quote`. Without +`FMP_BASE_URL` it targets `https://financialmodelingprep.com`, and without +`FMP_API_KEY` it returns a `MissingCredential` configuration error. Both values +are trimmed and a blank value counts as unset. A plaintext `http://` base URL +to a non-loopback host is still refused unless the builder opts in with +`danger_allow_insecure_authentication(true)`. `Client::builder()` itself never +reads either variable. + ## Custom router or proxy Transport choices do not change endpoint code. A caller can select a custom base URL and path prefix, no auth, FMP header or query auth, bearer auth, custom secret header or query auth, and arbitrary default headers. diff --git a/crates/libfmp/src/client.rs b/crates/libfmp/src/client.rs index 0dae892..1231f77 100644 --- a/crates/libfmp/src/client.rs +++ b/crates/libfmp/src/client.rs @@ -21,7 +21,10 @@ use url::Url; use crate::{ Result, VERSION, - config::{Authentication, DEFAULT_BASE_URL, DEFAULT_PATH_PREFIX, RedirectPolicy}, + config::{ + Authentication, DEFAULT_BASE_URL, DEFAULT_PATH_PREFIX, RedirectPolicy, + fmp_api_key_from_env, fmp_base_url_from_env, + }, endpoints::{ DecodeFailure, ExpectedContentType, QueryEncoder, ResponseMetadata, is_provider_message, }, @@ -143,6 +146,69 @@ impl fmt::Debug for ClientBuilder { } impl ClientBuilder { + /// Starts a builder from the `FMP_API_KEY` and `FMP_BASE_URL` process + /// environment variables, for example to route through a proxy that + /// issues its own keys. + /// + /// The key becomes [`Authentication::FmpHeader`] (the `apikey` header); + /// the base URL, when set, replaces [`DEFAULT_BASE_URL`] and keeps its + /// path, so `https://valet.bitbrew.app/fmp` sends the quote endpoint to + /// `https://valet.bitbrew.app/fmp/stable/quote`. The path prefix stays + /// [`DEFAULT_PATH_PREFIX`]. Both values are trimmed, and an empty or + /// whitespace-only value counts as unset (see + /// [`crate::config::fmp_api_key_from_env`] and + /// [`crate::config::fmp_base_url_from_env`]). + /// + /// This is the only constructor that reads the environment: + /// [`Client::builder`] never does. The returned builder already has its + /// authentication selected, so calling [`Self::authentication`] on it + /// fails at [`Self::build`] with + /// [`ConfigurationErrorKind::ConflictingAuthentication`]; every other + /// setter, including [`Self::base_url`], still applies. + /// + /// # Errors + /// + /// Returns [`ConfigurationErrorKind::MissingCredential`] when + /// `FMP_API_KEY` is unset or blank. An `FMP_BASE_URL` that is not an + /// absolute HTTP(S) URL fails at [`Self::build`] with + /// [`ConfigurationErrorKind::InvalidBaseUrl`] or + /// [`ConfigurationErrorKind::UnsafeBaseUrl`], and a plaintext + /// non-loopback `http://` URL with + /// [`ConfigurationErrorKind::InsecureAuthentication`] unless + /// [`Self::danger_allow_insecure_authentication`] is set. + /// + /// ```no_run + /// use std::time::Duration; + /// + /// use libfmp::ClientBuilder; + /// + /// # fn main() -> libfmp::Result<()> { + /// // FMP_API_KEY=vk_... FMP_BASE_URL=https://valet.bitbrew.app/fmp + /// let client = ClientBuilder::from_env()? + /// .timeout(Duration::from_secs(10)) + /// .build()?; + /// # let _ = client; + /// # Ok(()) + /// # } + /// ``` + pub fn from_env() -> Result { + Self::from_env_values(fmp_api_key_from_env(), fmp_base_url_from_env()) + } + + fn from_env_values(api_key: Option, base_url: Option) -> Result { + let api_key = api_key.ok_or_else(|| { + Error::configuration_with_kind( + ConfigurationErrorKind::MissingCredential, + "FMP_API_KEY is not set", + ) + })?; + let builder = Self::default().authentication(Authentication::fmp_header(api_key)); + Ok(match base_url { + Some(base_url) => builder.base_url(base_url), + None => builder, + }) + } + /// Sets an absolute HTTP(S) base URL. Existing base path segments are kept. pub fn base_url(mut self, base_url: impl Into) -> Self { self.base_url = base_url.into(); @@ -358,10 +424,20 @@ impl fmt::Debug for Client { impl Client { /// Starts a client builder with the direct FMP stable API defaults. + /// + /// The builder never reads the environment; see [`Self::from_env`]. pub fn builder() -> ClientBuilder { ClientBuilder::default() } + /// Builds a client from `FMP_API_KEY` and `FMP_BASE_URL`. + /// + /// Shorthand for `ClientBuilder::from_env()?.build()`; see + /// [`ClientBuilder::from_env`] for the rules and errors. + pub fn from_env() -> Result { + ClientBuilder::from_env()?.build() + } + /// Executes a typed endpoint using only transport-owned default headers. pub async fn execute(&self, endpoint: &EndpointSpec) -> Result where @@ -1174,6 +1250,94 @@ mod tests { ); } + fn env_client(api_key: Option<&str>, base_url: Option<&str>) -> Result { + ClientBuilder::from_env_values(api_key.map(str::to_owned), base_url.map(str::to_owned))? + .build() + } + + #[test] + fn env_constructor_targets_a_proxy_base_with_the_apikey_header() { + let client = env_client(Some("vk_example"), Some("https://valet.bitbrew.app/fmp")).unwrap(); + let url = + build_endpoint_url(&client.inner.base_url, &client.inner.path_prefix, "quote").unwrap(); + assert_eq!(url.as_str(), "https://valet.bitbrew.app/fmp/stable/quote"); + let (name, value) = client.inner.auth.header.as_ref().unwrap(); + assert_eq!(name.as_str(), "apikey"); + assert_eq!(value, "vk_example"); + assert!(client.inner.auth.query.is_none()); + } + + #[test] + fn env_constructor_without_a_base_url_keeps_the_fmp_origin() { + let client = env_client(Some("vk_example"), None).unwrap(); + let url = + build_endpoint_url(&client.inner.base_url, &client.inner.path_prefix, "quote").unwrap(); + assert_eq!( + url.as_str(), + "https://financialmodelingprep.com/stable/quote" + ); + } + + #[test] + fn env_constructor_reports_typed_configuration_errors() { + for (api_key, base_url, kind) in [ + (None, None, ConfigurationErrorKind::MissingCredential), + ( + None, + Some("https://valet.bitbrew.app/fmp"), + ConfigurationErrorKind::MissingCredential, + ), + ( + Some("vk_example"), + Some("valet.bitbrew.app/fmp"), + ConfigurationErrorKind::InvalidBaseUrl, + ), + ( + Some("vk_example"), + Some("https://user:pass@valet.bitbrew.app/fmp"), + ConfigurationErrorKind::UnsafeBaseUrl, + ), + ( + Some("vk_example"), + Some("http://example.test/fmp"), + ConfigurationErrorKind::InsecureAuthentication, + ), + ] { + let error = env_client(api_key, base_url).unwrap_err(); + assert_eq!(error.configuration_kind(), Some(kind)); + assert!(!error.to_string().contains("vk_example")); + } + } + + #[test] + fn env_constructor_keeps_the_loopback_and_dangerous_http_rules() { + assert!(env_client(Some("vk_example"), Some("http://127.0.0.1:8080/fmp")).is_ok()); + let builder = ClientBuilder::from_env_values( + Some("vk_example".to_owned()), + Some("http://example.test/fmp".to_owned()), + ) + .unwrap(); + assert!( + builder + .danger_allow_insecure_authentication(true) + .build() + .is_ok() + ); + } + + #[test] + fn env_constructor_rejects_a_second_authentication() { + let error = ClientBuilder::from_env_values(Some("vk_example".to_owned()), None) + .unwrap() + .authentication(Authentication::bearer("other")) + .build() + .unwrap_err(); + assert_eq!( + error.configuration_kind(), + Some(ConfigurationErrorKind::ConflictingAuthentication) + ); + } + #[test] fn default_response_limit_is_the_documented_finite_value() { assert_eq!(DEFAULT_MAX_RESPONSE_BODY_BYTES, 64 * 1024 * 1024); diff --git a/crates/libfmp/src/config.rs b/crates/libfmp/src/config.rs index 16cb5f5..f9cf198 100644 --- a/crates/libfmp/src/config.rs +++ b/crates/libfmp/src/config.rs @@ -32,7 +32,40 @@ pub fn fmp_api_key_from_env() -> Option { fmp_api_key_from_value(std::env::var(FMP_API_KEY_ENV).ok().as_deref()) } +/// The process environment variable that [`fmp_base_url_from_env`] reads. +pub const FMP_BASE_URL_ENV: &str = "FMP_BASE_URL"; + +/// Reads a base URL override, such as a proxy origin, from the +/// `FMP_BASE_URL` process environment variable. +/// +/// Surrounding whitespace is trimmed; an unset, empty, or whitespace-only +/// value yields `None`. The value is not validated here: +/// [`crate::ClientBuilder::build`] rejects anything that is not an absolute +/// HTTP(S) URL. [`crate::ClientBuilder::from_env`] applies it together with +/// the key. +/// +/// ``` +/// use libfmp::config::{FMP_BASE_URL_ENV, fmp_base_url_from_env}; +/// +/// let expected = std::env::var(FMP_BASE_URL_ENV) +/// .ok() +/// .map(|value| value.trim().to_owned()) +/// .filter(|value| !value.is_empty()); +/// assert_eq!(fmp_base_url_from_env(), expected); +/// ``` +pub fn fmp_base_url_from_env() -> Option { + fmp_base_url_from_value(std::env::var(FMP_BASE_URL_ENV).ok().as_deref()) +} + fn fmp_api_key_from_value(value: Option<&str>) -> Option { + trimmed_non_empty(value) +} + +fn fmp_base_url_from_value(value: Option<&str>) -> Option { + trimmed_non_empty(value) +} + +fn trimmed_non_empty(value: Option<&str>) -> Option { let trimmed = value?.trim(); if trimmed.is_empty() { None @@ -197,5 +230,20 @@ mod tests { Authentication::fmp_header_from_env(), expected.map(Authentication::fmp_header) ); + assert_eq!( + fmp_base_url_from_env(), + fmp_base_url_from_value(std::env::var(FMP_BASE_URL_ENV).ok().as_deref()) + ); + } + + #[test] + fn env_base_url_is_trimmed_and_absent_when_blank() { + assert_eq!(fmp_base_url_from_value(None), None); + assert_eq!(fmp_base_url_from_value(Some("")), None); + assert_eq!(fmp_base_url_from_value(Some(" \t\n")), None); + assert_eq!( + fmp_base_url_from_value(Some(" https://valet.bitbrew.app/fmp\n")).as_deref(), + Some("https://valet.bitbrew.app/fmp") + ); } } diff --git a/sdk/go/README.md b/sdk/go/README.md index 1fbe49c..3dacbb4 100644 --- a/sdk/go/README.md +++ b/sdk/go/README.md @@ -75,6 +75,30 @@ append the secret as the last query pair of every request. `WithBaseURL` and `WithPathPrefix` route the client through a proxy; `WithHTTPClient` injects a caller-owned `*http.Client` for tests and custom transports. +### Using a proxy + +A proxy that issues its own keys and forwards to FMP, such as +[valet](https://valet.bitbrew.app), needs only `FMP_API_KEY=vk_...` and +`FMP_BASE_URL=https://valet.bitbrew.app/fmp` in the environment: + +```go +client, err := fmp.NewClientFromEnv(fmp.WithTimeout(20 * time.Second)) +if err != nil { + return err +} +``` + +`NewClientFromEnv` sends the key in the `apikey` header and keeps the base +URL's path, so the quote endpoint goes to +`https://valet.bitbrew.app/fmp/stable/quote`. Without `FMP_BASE_URL` it +targets the FMP origin; without `FMP_API_KEY` it returns +`ConfigurationKindMissingCredential`. Options are applied after the +environment, so `WithBaseURL` wins, but `WithAuthentication` conflicts with +the environment key (use `NewClient` to pick another mode). Plaintext +`http://` to a non-loopback host still needs +`WithDangerAllowInsecureAuthentication`. `NewClient` never reads either +variable. + `client.CloseIdleConnections()` closes the idle keep-alive connections of the transport `NewClient` built; the client stays usable. It is a no-op with `WithHTTPClient`, whose transport stays with the caller. There is no `Close`: @@ -113,11 +137,13 @@ if err != nil { } ``` -The environment names match the Rust crate's live opt-in tests: +The environment names match the Rust crate (`Client::from_env`) and its live +opt-in tests: | Variable | Read by | | --- | --- | -| `FMP_API_KEY` | `FMPHeaderFromEnv` and `APIKeyFromEnv` | +| `FMP_API_KEY` | `FMPHeaderFromEnv`, `APIKeyFromEnv`, and `NewClientFromEnv` | +| `FMP_BASE_URL` | `BaseURLFromEnv` and `NewClientFromEnv` | | `FMP_LIVE_TESTS` | live tests (`live_test.go`), which run only when it is `1` | | `FMP_PROXY_BASE_URL`, `FMP_PROXY_TOKEN`, `FMP_PROXY_PATH_PREFIX`, `FMP_TENANT` | live tests against a proxy | diff --git a/sdk/go/auth.go b/sdk/go/auth.go index ea2c3e8..e9663e2 100644 --- a/sdk/go/auth.go +++ b/sdk/go/auth.go @@ -9,6 +9,10 @@ import ( // EnvAPIKey is the process environment variable read by FMPHeaderFromEnv. const EnvAPIKey = "FMP_API_KEY" +// EnvBaseURL is the process environment variable read by BaseURLFromEnv, +// typically a proxy origin such as https://valet.bitbrew.app/fmp. +const EnvBaseURL = "FMP_BASE_URL" + const ( fmpHeaderName = "apikey" fmpQueryName = "apikey" @@ -60,6 +64,44 @@ func FMPHeaderFromEnv() (Authentication, bool) { return FMPHeader(key), true } +// BaseURLFromEnv reads FMP_BASE_URL, trims surrounding whitespace, and +// reports false when the variable is unset, empty, or whitespace-only. The +// value is not validated here; NewClient rejects anything that is not an +// absolute HTTP(S) URL. +func BaseURLFromEnv() (string, bool) { + value := strings.TrimSpace(os.Getenv(EnvBaseURL)) + return value, value != "" +} + +// NewClientFromEnv builds a client from FMP_API_KEY and FMP_BASE_URL, for +// example to route through a proxy that issues its own keys. The key is sent +// as FMPHeader (the "apikey" header). FMP_BASE_URL, when set, replaces +// DefaultBaseURL and keeps its path, so https://valet.bitbrew.app/fmp sends +// the quote endpoint to https://valet.bitbrew.app/fmp/stable/quote; the path +// prefix stays DefaultPathPrefix. Both values use the normalization of +// APIKeyFromEnv and BaseURLFromEnv. +// +// The options are applied after the environment, so WithBaseURL and the +// other options win; WithAuthentication cannot be combined with it (the +// client would have two authentication selections and NewClient reports +// ConfigurationKindConflictingAuthentication). An unset or blank +// FMP_API_KEY is ConfigurationKindMissingCredential. Every NewClient rule +// still applies, including the refusal of authenticated plaintext HTTP to a +// non-loopback host without WithDangerAllowInsecureAuthentication. NewClient +// itself never reads either variable. +func NewClientFromEnv(opts ...Option) (*Client, error) { + auth, ok := FMPHeaderFromEnv() + if !ok { + return nil, configurationError(ConfigurationKindMissingCredential, + "FMP_API_KEY is not set") + } + envOpts := []Option{WithAuthentication(auth)} + if baseURL, ok := BaseURLFromEnv(); ok { + envOpts = append(envOpts, WithBaseURL(baseURL)) + } + return NewClient(append(envOpts, opts...)...) +} + // FMPQuery sends the FMP API key in the "apikey" query parameter. func FMPQuery(apiKey string) Authentication { return Authentication{mode: authFMPQuery, name: fmpQueryName, secret: apiKey} diff --git a/sdk/go/auth_test.go b/sdk/go/auth_test.go index 1feb46e..6d04b6b 100644 --- a/sdk/go/auth_test.go +++ b/sdk/go/auth_test.go @@ -328,3 +328,122 @@ func assertNoSecret(t *testing.T, secret string, texts ...string) { } } } + +func setOrUnsetEnv(t *testing.T, name, value string, set bool) { + t.Helper() + t.Setenv(name, value) + if !set { + if err := os.Unsetenv(name); err != nil { + t.Fatal(err) + } + } +} + +func TestBaseURLFromEnvNormalizesTheVariable(t *testing.T) { + cases := []struct { + name string + value string + set bool + want string + ok bool + }{ + {name: "unset"}, + {name: "empty", set: true}, + {name: "blank", value: " \t\n", set: true}, + {name: "padded", value: " https://valet.bitbrew.app/fmp\n", set: true, + want: "https://valet.bitbrew.app/fmp", ok: true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + setOrUnsetEnv(t, EnvBaseURL, tc.value, tc.set) + got, ok := BaseURLFromEnv() + if got != tc.want || ok != tc.ok { + t.Fatalf("BaseURLFromEnv() = %q, %v; want %q, %v", got, ok, tc.want, tc.ok) + } + }) + } +} + +func TestNewClientFromEnvTargetsAProxyBaseWithTheAPIKeyHeader(t *testing.T) { + t.Setenv(EnvAPIKey, "vk_example") + t.Setenv(EnvBaseURL, "https://valet.bitbrew.app/fmp") + client, err := NewClientFromEnv() + if err != nil { + t.Fatal(err) + } + if got := client.buildEndpointURL("quote").String(); got != "https://valet.bitbrew.app/fmp/stable/quote" { + t.Fatalf("endpoint URL = %q", got) + } + if client.auth.headerName != "apikey" || client.auth.headerValue != "vk_example" || client.auth.queryName != "" { + t.Fatalf("auth = header %q, query %q", client.auth.headerName, client.auth.queryName) + } +} + +func TestNewClientFromEnvWithoutABaseURLKeepsTheFMPOrigin(t *testing.T) { + t.Setenv(EnvAPIKey, "vk_example") + setOrUnsetEnv(t, EnvBaseURL, "", false) + client, err := NewClientFromEnv() + if err != nil { + t.Fatal(err) + } + if got := client.buildEndpointURL("quote").String(); got != DefaultBaseURL+"/stable/quote" { + t.Fatalf("endpoint URL = %q", got) + } +} + +func TestNewClientFromEnvAppliesOptionsAfterTheEnvironment(t *testing.T) { + t.Setenv(EnvAPIKey, "vk_example") + t.Setenv(EnvBaseURL, "https://valet.bitbrew.app/fmp") + client, err := NewClientFromEnv(WithBaseURL("https://proxy.example/router"), WithPathPrefix("")) + if err != nil { + t.Fatal(err) + } + if got := client.buildEndpointURL("quote").String(); got != "https://proxy.example/router/quote" { + t.Fatalf("endpoint URL = %q", got) + } + _, err = NewClientFromEnv(WithAuthentication(Bearer("other"))) + assertConfigurationKind(t, err, ConfigurationKindConflictingAuthentication) +} + +func TestNewClientFromEnvReportsTypedConfigurationErrors(t *testing.T) { + cases := []struct { + name string + key string + keySet bool + baseURL string + want ConfigurationKind + }{ + {name: "missing key", want: ConfigurationKindMissingCredential}, + {name: "blank key", key: " ", keySet: true, baseURL: "https://valet.bitbrew.app/fmp", + want: ConfigurationKindMissingCredential}, + {name: "relative base", key: "vk_example", keySet: true, baseURL: "valet.bitbrew.app/fmp", + want: ConfigurationKindInvalidBaseURL}, + {name: "credentials in base", key: "vk_example", keySet: true, + baseURL: "https://user:pass@valet.bitbrew.app/fmp", want: ConfigurationKindUnsafeBaseURL}, + {name: "plaintext base", key: "vk_example", keySet: true, baseURL: "http://example.test/fmp", + want: ConfigurationKindInsecureAuthentication}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + setOrUnsetEnv(t, EnvAPIKey, tc.key, tc.keySet) + setOrUnsetEnv(t, EnvBaseURL, tc.baseURL, tc.baseURL != "") + _, err := NewClientFromEnv() + assertConfigurationKind(t, err, tc.want) + if strings.Contains(err.Error(), "vk_example") { + t.Fatalf("error leaked the key: %v", err) + } + }) + } +} + +func TestNewClientFromEnvKeepsTheLoopbackAndDangerousHTTPRules(t *testing.T) { + t.Setenv(EnvAPIKey, "vk_example") + t.Setenv(EnvBaseURL, "http://127.0.0.1:8080/fmp") + if _, err := NewClientFromEnv(); err != nil { + t.Fatalf("loopback HTTP: %v", err) + } + t.Setenv(EnvBaseURL, "http://example.test/fmp") + if _, err := NewClientFromEnv(WithDangerAllowInsecureAuthentication()); err != nil { + t.Fatalf("dangerous opt-in: %v", err) + } +} diff --git a/sdk/go/doc.go b/sdk/go/doc.go index 5b97991..916107d 100644 --- a/sdk/go/doc.go +++ b/sdk/go/doc.go @@ -20,6 +20,13 @@ // } // client, err := fmp.NewClient(fmp.WithAuthentication(auth)) // +// NewClientFromEnv is the shorthand for a proxy that issues its own keys, +// such as valet: with FMP_API_KEY=vk_... and +// FMP_BASE_URL=https://valet.bitbrew.app/fmp it sends the key in the +// "apikey" header to https://valet.bitbrew.app/fmp/stable/quote and so on. +// Without FMP_BASE_URL it targets DefaultBaseURL. Options given to it are +// applied after the environment, so WithBaseURL wins. +// // Header authentication modes (FMPHeader, Bearer, CustomHeader, // CustomHeaderWithPrefix) never place a credential in the URL. Query modes // (FMPQuery, CustomQuery) append the secret as the last query pair of every @@ -62,8 +69,9 @@ // // # Environment // -// FMPHeaderFromEnv reads FMP_API_KEY. NewClient never reads a credential -// from the environment; it consults the proxy variables (HTTP_PROXY, +// FMPHeaderFromEnv reads FMP_API_KEY, BaseURLFromEnv reads FMP_BASE_URL, +// and NewClientFromEnv reads both. NewClient never reads a credential or a +// base URL from the environment; it consults the proxy variables (HTTP_PROXY, // HTTPS_PROXY, NO_PROXY) once through the transport's proxy function, as the // first request would, to register the proxy host for cause redaction. The // opt-in live tests (live_test.go) use the same names as the Rust crate's