diff --git a/_database/sql/migrations_squashed/generic_inference_jobs.sql b/_database/sql/migrations_squashed/generic_inference_jobs.sql index 2eca56ff0e..44ce22b635 100644 --- a/_database/sql/migrations_squashed/generic_inference_jobs.sql +++ b/_database/sql/migrations_squashed/generic_inference_jobs.sql @@ -208,7 +208,8 @@ CREATE TABLE generic_inference_jobs ( -- Multiple debug_logs rows can share the same event token. maybe_debug_log_event_token VARCHAR(32) DEFAULT NULL, - -- The platform the enqueuing request came from ('web', 'desktop_app', 'curl'), + -- The platform the enqueuing request came from ('web', 'desktop_app', + -- 'curl', 'python_requests', 'postman'), -- inferred from the request's User-Agent header. platform_type VARCHAR(16) DEFAULT NULL, diff --git a/_database/sql/migrations_squashed/media_files.sql b/_database/sql/migrations_squashed/media_files.sql index d3a6afcb06..843eacf9b9 100644 --- a/_database/sql/migrations_squashed/media_files.sql +++ b/_database/sql/migrations_squashed/media_files.sql @@ -65,7 +65,8 @@ CREATE TABLE media_files ( -- What service provided the generation (if any). maybe_generation_provider VARCHAR(16) DEFAULT NULL, - -- The platform the creating request came from ('web', 'desktop_app', 'curl'), + -- The platform the creating request came from ('web', 'desktop_app', + -- 'curl', 'python_requests', 'postman'), -- inferred from the request's User-Agent header. Copied from the originating -- generic_inference_jobs row for inference results. platform_type VARCHAR(16) DEFAULT NULL, diff --git a/crates/schema/public/enums/src/common/platform_type.rs b/crates/schema/public/enums/src/common/platform_type.rs index 12e201ed1a..b543789fb0 100644 --- a/crates/schema/public/enums/src/common/platform_type.rs +++ b/crates/schema/public/enums/src/common/platform_type.rs @@ -21,6 +21,8 @@ pub enum PlatformType { Web, DesktopApp, Curl, + PythonRequests, + Postman, } impl_enum_display_and_debug_using_to_str!(PlatformType); @@ -33,6 +35,8 @@ impl PlatformType { Self::Web => "web", Self::DesktopApp => "desktop_app", Self::Curl => "curl", + Self::PythonRequests => "python_requests", + Self::Postman => "postman", } } @@ -41,6 +45,8 @@ impl PlatformType { "web" => Ok(Self::Web), "desktop_app" => Ok(Self::DesktopApp), "curl" => Ok(Self::Curl), + "python_requests" => Ok(Self::PythonRequests), + "postman" => Ok(Self::Postman), _ => Err(EnumError::CouldNotConvertFromString(value.to_string())), } } @@ -51,6 +57,8 @@ impl PlatformType { Self::Web, Self::DesktopApp, Self::Curl, + Self::PythonRequests, + Self::Postman, ]) } } @@ -69,6 +77,8 @@ mod tests { assert_serialization(PlatformType::Web, "web"); assert_serialization(PlatformType::DesktopApp, "desktop_app"); assert_serialization(PlatformType::Curl, "curl"); + assert_serialization(PlatformType::PythonRequests, "python_requests"); + assert_serialization(PlatformType::Postman, "postman"); } #[test] @@ -76,6 +86,8 @@ mod tests { assert_eq!(PlatformType::Web.to_str(), "web"); assert_eq!(PlatformType::DesktopApp.to_str(), "desktop_app"); assert_eq!(PlatformType::Curl.to_str(), "curl"); + assert_eq!(PlatformType::PythonRequests.to_str(), "python_requests"); + assert_eq!(PlatformType::Postman.to_str(), "postman"); } #[test] @@ -83,6 +95,8 @@ mod tests { assert_eq!(PlatformType::from_str("web").unwrap(), PlatformType::Web); assert_eq!(PlatformType::from_str("desktop_app").unwrap(), PlatformType::DesktopApp); assert_eq!(PlatformType::from_str("curl").unwrap(), PlatformType::Curl); + assert_eq!(PlatformType::from_str("python_requests").unwrap(), PlatformType::PythonRequests); + assert_eq!(PlatformType::from_str("postman").unwrap(), PlatformType::Postman); } #[test] @@ -99,10 +113,12 @@ mod tests { #[test] fn all_variants() { let mut variants = PlatformType::all_variants(); - assert_eq!(variants.len(), 3); + assert_eq!(variants.len(), 5); assert_eq!(variants.pop_first(), Some(PlatformType::Web)); assert_eq!(variants.pop_first(), Some(PlatformType::DesktopApp)); assert_eq!(variants.pop_first(), Some(PlatformType::Curl)); + assert_eq!(variants.pop_first(), Some(PlatformType::PythonRequests)); + assert_eq!(variants.pop_first(), Some(PlatformType::Postman)); assert_eq!(variants.pop_first(), None); } } diff --git a/crates/service/web/storyteller_web/src/http_server/web_utils/get_request_platform_type.rs b/crates/service/web/storyteller_web/src/http_server/web_utils/get_request_platform_type.rs index 7d13f62d58..e2ee01e16e 100644 --- a/crates/service/web/storyteller_web/src/http_server/web_utils/get_request_platform_type.rs +++ b/crates/service/web/storyteller_web/src/http_server/web_utils/get_request_platform_type.rs @@ -9,11 +9,24 @@ const DESKTOP_CLIENT_USER_AGENT_PREFIX : &str = "storyteller-client"; /// User-Agent prefix sent by the curl CLI tool, eg. "curl/8.7.1". const CURL_USER_AGENT_PREFIX : &str = "curl"; +/// User-Agent prefix sent by the Python `requests` library. +/// Modern releases send "python-requests/2.31.0" (optionally followed by +/// "CPython/x.y.z OS/release" in older releases, eg. +/// "python-requests/0.14.2 CPython/2.7.3 Linux/3.2.0"). Some early releases +/// sent the bare "python-requests.org", which shares this prefix. +const PYTHON_REQUESTS_USER_AGENT_PREFIX : &str = "python-requests"; + +/// User-Agent prefix sent by Postman, eg. "PostmanRuntime/7.36.0". +/// All known Postman Runtime versions (2.x through 8.x) use this form. +/// (Compared case-insensitively.) +const POSTMAN_USER_AGENT_PREFIX : &str = "postmanruntime"; + /// Infer the calling platform from the request's User-Agent header. /// /// Returns `None` if the header is absent or unreadable. Otherwise, the ArtCraft -/// desktop client identifies itself with a "storyteller-client" prefix, curl-like -/// CLI callers are flagged as such, and anything else is assumed to be a browser. +/// desktop client identifies itself with a "storyteller-client" prefix, known +/// CLI/API tools (curl, python-requests, Postman) are flagged as such, and +/// anything else is assumed to be a browser. pub fn get_request_platform_type(http_request: &HttpRequest) -> Option { let user_agent_header_name = HeaderName::from_static("user-agent"); let header_map : &HeaderMap = http_request.headers(); @@ -32,9 +45,16 @@ fn platform_type_from_user_agent(user_agent: &str) -> Option { if user_agent.starts_with(DESKTOP_CLIENT_USER_AGENT_PREFIX) { return Some(PlatformType::DesktopApp); } - if user_agent.to_ascii_lowercase().starts_with(CURL_USER_AGENT_PREFIX) { + let lowercase_user_agent = user_agent.to_ascii_lowercase(); + if lowercase_user_agent.starts_with(CURL_USER_AGENT_PREFIX) { return Some(PlatformType::Curl); } + if lowercase_user_agent.starts_with(PYTHON_REQUESTS_USER_AGENT_PREFIX) { + return Some(PlatformType::PythonRequests); + } + if lowercase_user_agent.starts_with(POSTMAN_USER_AGENT_PREFIX) { + return Some(PlatformType::Postman); + } Some(PlatformType::Web) } @@ -69,6 +89,20 @@ mod tests { Some(PlatformType::Curl)); } + #[test] + fn python_requests_user_agent() { + assert_eq!( + platform_type_for_user_agent_header("python-requests/2.31.0"), + Some(PlatformType::PythonRequests)); + } + + #[test] + fn postman_user_agent() { + assert_eq!( + platform_type_for_user_agent_header("PostmanRuntime/7.36.0"), + Some(PlatformType::Postman)); + } + #[test] fn browser_user_agent() { assert_eq!( @@ -105,10 +139,43 @@ mod tests { assert_eq!(platform_type_from_user_agent("Mozilla/5.0 (Windows NT 10.0; Win64; x64)"), Some(PlatformType::Web)); } + #[test] + fn python_requests_versions() { + // Modern releases: bare "python-requests/". + assert_eq!(platform_type_from_user_agent("python-requests/2.31.0"), Some(PlatformType::PythonRequests)); + assert_eq!(platform_type_from_user_agent("python-requests/2.32.3"), Some(PlatformType::PythonRequests)); + // Older releases appended runtime and OS details. + assert_eq!( + platform_type_from_user_agent("python-requests/0.14.2 CPython/2.7.3 Linux/3.2.0-armv7l"), + Some(PlatformType::PythonRequests)); + assert_eq!( + platform_type_from_user_agent("python-requests/1.1.0 CPython/2.6.6 Linux/2.6.32-431.3.1.el6.x86_64"), + Some(PlatformType::PythonRequests)); + // Some early releases sent the bare project domain. + assert_eq!(platform_type_from_user_agent("python-requests.org"), Some(PlatformType::PythonRequests)); + // Case-insensitive. + assert_eq!(platform_type_from_user_agent("Python-Requests/2.25.1"), Some(PlatformType::PythonRequests)); + } + + #[test] + fn postman_versions() { + // Modern Postman Runtime 7.x/8.x. + assert_eq!(platform_type_from_user_agent("PostmanRuntime/7.36.0"), Some(PlatformType::Postman)); + assert_eq!(platform_type_from_user_agent("PostmanRuntime/8.0.3"), Some(PlatformType::Postman)); + // Historical Postman Runtime versions. + assert_eq!(platform_type_from_user_agent("PostmanRuntime/2.4.5"), Some(PlatformType::Postman)); + assert_eq!(platform_type_from_user_agent("PostmanRuntime/7.6.0"), Some(PlatformType::Postman)); + // Case-insensitive, with surrounding whitespace. + assert_eq!(platform_type_from_user_agent(" postmanruntime/7.26.8 "), Some(PlatformType::Postman)); + } + #[test] fn unknown_tools_are_web() { - assert_eq!(platform_type_from_user_agent("python-requests/2.31.0"), Some(PlatformType::Web)); - assert_eq!(platform_type_from_user_agent("PostmanRuntime/7.36.0"), Some(PlatformType::Web)); + assert_eq!(platform_type_from_user_agent("Go-http-client/2.0"), Some(PlatformType::Web)); + assert_eq!(platform_type_from_user_agent("insomnia/8.6.1"), Some(PlatformType::Web)); + // Other Python HTTP clients are not the `requests` library. + assert_eq!(platform_type_from_user_agent("python-httpx/0.27.0"), Some(PlatformType::Web)); + assert_eq!(platform_type_from_user_agent("Python-urllib/3.11"), Some(PlatformType::Web)); } #[test]