From b10ecdcba4edd3242b0b4f130c975ea203ae003d Mon Sep 17 00:00:00 2001 From: benbenbang Date: Tue, 6 Oct 2026 17:30:10 +0800 Subject: [PATCH 1/3] feat(config): add from_env constructors for proxy base URL and key This change lets callers build a client from the process environment, which simplifies routing through a proxy that issues its own keys and forwards to FMP without any code change. **Environment-driven client construction:** * Added `ClientBuilder::from_env` and `Client::from_env`, which read `FMP_API_KEY` and `FMP_BASE_URL` to select the `apikey` header authentication and an optional base URL override while keeping the default path prefix. * The key becomes `Authentication::FmpHeader`; a set base URL replaces the default origin and keeps its path, so `https://valet.bitbrew.app/fmp` sends the quote endpoint to `https://valet.bitbrew.app/fmp/stable/quote`. * A missing or blank `FMP_API_KEY` yields `MissingCredential`; an invalid, unsafe, or insecure base URL reports its typed configuration error at build time, and selecting a second authentication fails with `ConflictingAuthentication`. `Client::builder` never reads the environment. **Base URL environment reader:** * Added `FMP_BASE_URL_ENV` and `fmp_base_url_from_env` in config, which trim surrounding whitespace and treat an empty or whitespace-only value as unset, factored through a shared `trimmed_non_empty` helper. **Tests and documentation:** * Added tests covering the proxy base URL, the default FMP origin fallback, the typed error cases, loopback and dangerous-http rules, conflicting authentication, and base URL trimming. * Documented the proxy workflow in the README and switched the custom-router example to the `FMP_BASE_URL` variable. --- crates/libfmp/README.md | 32 +++++++ crates/libfmp/src/client.rs | 166 +++++++++++++++++++++++++++++++++++- crates/libfmp/src/config.rs | 48 +++++++++++ 3 files changed, 245 insertions(+), 1 deletion(-) diff --git a/crates/libfmp/README.md b/crates/libfmp/README.md index c62fe6ac..5abe5907 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 0dae892f..1231f776 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 16cb5f5e..f9cf1983 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") + ); } } From eaae71f02fd4d6c24d7b10a5f7183a989efd5492 Mon Sep 17 00:00:00 2001 From: benbenbang Date: Tue, 6 Oct 2026 17:38:27 +0800 Subject: [PATCH 2/3] feat(sdk/go): add NewClientFromEnv for proxy-issued keys This commit introduces an environment-based constructor so callers can route through a proxy that issues its own keys, such as valet, without wiring the options by hand. **New environment-based client:** * Added `NewClientFromEnv`, which reads `FMP_API_KEY` and sends it as the `apikey` header via `FMPHeader`, and `FMP_BASE_URL`, which replaces `DefaultBaseURL` while keeping its path so the quote endpoint resolves to the proxy origin (for example `https://valet.bitbrew.app/fmp/stable/quote`). * Added `BaseURLFromEnv`, which reads and trims `FMP_BASE_URL` and reports false when unset, empty, or whitespace-only, leaving validation to `NewClient`. * Exported the `EnvBaseURL` constant for the `FMP_BASE_URL` variable. * Applied options after the environment so `WithBaseURL` wins, while `WithAuthentication` conflicts with the environment key; a missing or blank key returns `ConfigurationKindMissingCredential`, and every `NewClient` rule still applies, including the refusal of plaintext HTTP to a non-loopback host without the danger opt-in. **Testing:** * Added tests covering base URL normalization, proxy targeting with the header auth, the fallback to the FMP origin, option precedence and the authentication conflict, typed configuration errors with no key leakage, and the loopback and dangerous-HTTP rules. **Documentation:** * Documented the new constructor and `FMP_BASE_URL` in the README and package docs, noting the environment names match the Rust crate. --- sdk/go/README.md | 30 ++++++++++- sdk/go/auth.go | 42 ++++++++++++++++ sdk/go/auth_test.go | 119 ++++++++++++++++++++++++++++++++++++++++++++ sdk/go/doc.go | 12 ++++- 4 files changed, 199 insertions(+), 4 deletions(-) diff --git a/sdk/go/README.md b/sdk/go/README.md index 1fbe49c0..3dacbb41 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 ea2c3e88..e9663e29 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 1feb46ee..6d04b6b9 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 5b97991f..916107db 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 From 40ae199b0c5b458ecee7069d1bdb25edd8241740 Mon Sep 17 00:00:00 2001 From: benbenbang Date: Tue, 6 Oct 2026 17:55:24 +0800 Subject: [PATCH 3/3] feat(fmp-py): read FMP_BASE_URL when base_url is omitted This commit lets `FmpClient` resolve its base URL from the `FMP_BASE_URL` environment variable when no explicit `base_url` is passed, so a proxy such as valet works with only `FMP_API_KEY` and `FMP_BASE_URL` set and no code change. **Functional change:** * Added `resolve_base_url` in client.rs, which prefers an explicit `base_url` and otherwise falls back to `fmp_base_url_from_env`; a blank variable keeps the default `https://financialmodelingprep.com` origin. * The variable only replaces the base URL and never selects or relaxes authentication, so the `token` and `FMP_API_KEY` precedence rules are unchanged. **Tests:** * Added tests/test_base_url_env.py to cover the new pickup and precedence. * The autouse `isolated_api_key_env` fixture now also unsets `FMP_BASE_URL` so the developer's shell cannot leak into the suite. **Docs:** * Documented the behaviour in README.md with a "Using a proxy" section and a precedence table, and in the `__init__.pyi` and client.rs docstrings. close #415 --- crates/fmp-py/README.md | 37 +++++++- crates/fmp-py/python/fmp/_native/__init__.pyi | 8 ++ crates/fmp-py/src/client.rs | 18 +++- crates/fmp-py/tests/conftest.py | 10 +- crates/fmp-py/tests/test_base_url_env.py | 93 +++++++++++++++++++ 5 files changed, 159 insertions(+), 7 deletions(-) create mode 100644 crates/fmp-py/tests/test_base_url_env.py diff --git a/crates/fmp-py/README.md b/crates/fmp-py/README.md index b82e9465..1a2d9ec4 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 2cf38466..b9e6ebda 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 f24f10ec..c72bfe1c 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 24bb1616..eb055c80 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 00000000..a4008d84 --- /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)