diff --git a/CHANGELOG.adoc b/CHANGELOG.adoc index 1091346d..d5f3182c 100644 --- a/CHANGELOG.adoc +++ b/CHANGELOG.adoc @@ -63,6 +63,16 @@ starter.start() + We'd like to remove the `HttpServerStarter` altogether, so let us know if you're still using it for some reason. +* https://github.com/oxidecomputer/dropshot/pull/1115[#1115] Dropshot now includes **experimental** support for hosting multiple versions of an API at a single server and routing to the correct version based on the incoming request. See documentation for details. If you don't care about this, you can mostly ignore it, but see "Breaking Changes" below. ++ +By "experimental" we only mean that the API may change in upcoming releases. + +=== Breaking Changes + +* Dropshot now expects that APIs use https://semver.org/[Semver] values for their version string. Concretely, this only means that the `version` argument to `ApiDescription::openapi` (which generates an OpenAPI document) must be a `semver::Version`. Previously, it was `AsRef`. +* If you're invoking `ApiEndpoint::new` directly or constructing one as a literal (both of which are uncommon), you must provide a new `ApiEndpointVersions` value describing which versions this endpoint implements. You can use `ApiEndpointVersions::All` if you don't care about versioning. + + == 0.12.0 (released 2024-09-26) https://github.com/oxidecomputer/dropshot/compare/v0.11.0\...v0.12.0[Full list of commits] diff --git a/Cargo.lock b/Cargo.lock index 7e8c9abc..05a9d617 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -384,6 +384,7 @@ dependencies = [ "rustls-pki-types", "schemars", "scopeguard", + "semver", "serde", "serde_json", "serde_path_to_error", @@ -419,6 +420,7 @@ dependencies = [ "proc-macro2", "quote", "schema", + "semver", "serde", "serde_tokenstream", "syn", @@ -1783,6 +1785,12 @@ dependencies = [ "libc", ] +[[package]] +name = "semver" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61697e0a1c7e512e84a621326239844a24d8207b4669b41bc18b32ea5cbf988b" + [[package]] name = "serde" version = "1.0.214" diff --git a/dropshot/Cargo.toml b/dropshot/Cargo.toml index 7b6075cd..b46a2be1 100644 --- a/dropshot/Cargo.toml +++ b/dropshot/Cargo.toml @@ -32,6 +32,7 @@ percent-encoding = "2.3.1" rustls = "0.22.4" rustls-pemfile = "2.1.3" scopeguard = "1.2.0" +semver = "1.0.23" serde_json = "1.0.132" serde_path_to_error = "0.1.16" serde_urlencoded = "0.7.1" @@ -105,12 +106,15 @@ trybuild = "1.0.101" # Used by the https examples and tests pem = "3.0" rcgen = "0.13.1" -# Using rustls-tls because it appears the rcgen-generated certificates are not -# supported by the native Windows APIs. -reqwest = { version = "0.12.9", features = ["json", "rustls-tls"] } # Used in a doc-test demonstrating the WebsocketUpgrade extractor. tokio-tungstenite = "0.24.0" +[dev-dependencies.reqwest] +version = "0.12.9" +# Using rustls-tls because it appears the rcgen-generated certificates are not +# supported by the native Windows APIs. +features = [ "json", "rustls-tls" ] + [dev-dependencies.rustls-pki-types] version = "1.10.0" # Needed for CertificateDer::into_owned diff --git a/dropshot/examples/api-trait-alternate.rs b/dropshot/examples/api-trait-alternate.rs index 74cc5598..53e1162c 100644 --- a/dropshot/examples/api-trait-alternate.rs +++ b/dropshot/examples/api-trait-alternate.rs @@ -143,14 +143,14 @@ mod api { pub(crate) counter: u64, } - // A simple function to generate an OpenAPI spec for the trait, without having - // a real implementation available. + // A simple function to generate an OpenAPI spec for the trait, without + // having a real implementation available. // - // If the interface and implementation (see below) are in different crates, then - // this function would live in the interface crate. + // If the interface and implementation (see below) are in different crates, + // then this function would live in the interface crate. pub(crate) fn generate_openapi_spec() -> String { let api = counter_api_mod::stub_api_description().unwrap(); - let spec = api.openapi("Counter Server", "1.0.0"); + let spec = api.openapi("Counter Server", semver::Version::new(1, 0, 0)); serde_json::to_string_pretty(&spec.json().unwrap()).unwrap() } } diff --git a/dropshot/examples/api-trait-websocket.rs b/dropshot/examples/api-trait-websocket.rs index 9ef21e6a..78c34725 100644 --- a/dropshot/examples/api-trait-websocket.rs +++ b/dropshot/examples/api-trait-websocket.rs @@ -43,14 +43,15 @@ mod api { pub(crate) counter: u8, } - // A simple function to generate an OpenAPI spec for the server, without having - // a real implementation available. + // A simple function to generate an OpenAPI spec for the server, without + // having a real implementation available. // - // If the interface and implementation (see below) are in different crates, then - // this function would live in the interface crate. + // If the interface and implementation (see below) are in different crates, + // then this function would live in the interface crate. pub(crate) fn generate_openapi_spec() -> String { let my_server = counter_api_mod::stub_api_description().unwrap(); - let spec = my_server.openapi("Counter Server", "1.0.0"); + let spec = + my_server.openapi("Counter Server", semver::Version::new(1, 0, 0)); serde_json::to_string_pretty(&spec.json().unwrap()).unwrap() } } diff --git a/dropshot/examples/api-trait.rs b/dropshot/examples/api-trait.rs index 6ab0c4a3..613dc5a1 100644 --- a/dropshot/examples/api-trait.rs +++ b/dropshot/examples/api-trait.rs @@ -54,14 +54,15 @@ mod api { pub(crate) counter: u64, } - // A simple function to generate an OpenAPI spec for the trait, without having - // a real implementation available. + // A simple function to generate an OpenAPI spec for the trait, without + // having a real implementation available. // - // If the interface and implementation (see below) are in different crates, then - // this function would live in the interface crate. + // If the interface and implementation (see below) are in different crates, + // then this function would live in the interface crate. pub(crate) fn generate_openapi_spec() -> String { let description = counter_api_mod::stub_api_description().unwrap(); - let spec = description.openapi("Counter Server", "1.0.0"); + let spec = description + .openapi("Counter Server", semver::Version::new(1, 0, 0)); serde_json::to_string_pretty(&spec.json().unwrap()).unwrap() } } diff --git a/dropshot/examples/petstore.rs b/dropshot/examples/petstore.rs index 9b4d7fc3..aa350761 100644 --- a/dropshot/examples/petstore.rs +++ b/dropshot/examples/petstore.rs @@ -12,7 +12,7 @@ fn main() -> Result<(), String> { api.register(update_pet_with_form).unwrap(); api.register(find_pets_by_tags).unwrap(); - api.openapi("Pet Shop", "") + api.openapi("Pet Shop", semver::Version::new(1, 0, 0)) .write(&mut std::io::stdout()) .map_err(|e| e.to_string())?; diff --git a/dropshot/examples/schema-with-example.rs b/dropshot/examples/schema-with-example.rs index 90d2077c..cd44fca0 100644 --- a/dropshot/examples/schema-with-example.rs +++ b/dropshot/examples/schema-with-example.rs @@ -51,7 +51,7 @@ fn main() -> Result<(), String> { let mut api = ApiDescription::new(); api.register(get_foo).unwrap(); - api.openapi("Examples", "0.0.0") + api.openapi("Examples", semver::Version::new(0, 0, 0)) .write(&mut std::io::stdout()) .map_err(|e| e.to_string())?; diff --git a/dropshot/examples/versioning.rs b/dropshot/examples/versioning.rs new file mode 100644 index 00000000..61ce0473 --- /dev/null +++ b/dropshot/examples/versioning.rs @@ -0,0 +1,262 @@ +// Copyright 2024 Oxide Computer Company + +//! Example using API versioning +//! +//! This example defines a bunch of API versions: +//! +//! - Versions 1.x contain an endpoint `GET /` that returns a `Thing1` type with +//! just one field: `thing1_early`. +//! - Versions 2.x and later contain an endpoint `GET /` that returns a `Thing1` +//! type with one field: `thing1_late`. +//! +//! The client chooses which version they want to use by specifying the +//! `dropshot-demo-version` header with their request. +//! +//! ## Generating OpenAPI specs +//! +//! You can generate the OpenAPI spec for 1.0.0 using: +//! +//! ```text +//! $ cargo run --example=versioning -- openapi 1.0.0 +//! ``` +//! +//! You'll see that the this spec contains one operation, it produces `Thing1`, +//! and that the `Thing1` type only has the one field `thing1_early`. It also +//! contains an operation that produces a `Thing2`. +//! +//! You can generate the OpenAPI spec for 2.0.0 and see that the corresponding +//! `Thing1` type has a different field, `thing1_late`, as expected. `Thing2` +//! is also present and unchanged. +//! +//! You can generate the OpenAPI spec for any other version. You'll see that +//! 0.9.0, for example, has only the `Thing2` type and its associated getter. +//! +//! ## Running the server +//! +//! Start the Dropshot HTTP server with: +//! +//! ```text +//! $ cargo run --example=versioning -- run +//! ``` +//! +//! The server will listen on 127.0.0.1:12345. You can use `curl` to make +//! requests. If we don't specify a version, we get an error: +//! +//! ```text +//! $ curl http://127.0.0.1:12345/thing1 +//! { +//! "request_id": "73f62e8a-b363-488a-b662-662814e306ee", +//! "message": "missing expected header \"dropshot-demo-version\"" +//! } +//! ``` +//! +//! You can customize this behavior for your Dropshot server, but this one +//! requires that the client specify a version. +//! +//! If we provide a bogus one, we'll also get an error: +//! +//! ```text +//! $ curl -H 'dropshot-demo-version: threeve' http://127.0.0.1:12345/thing1 +//! { +//! "request_id": "18c1964e-88c6-4122-8287-1f2f399871bd", +//! "message": "bad value for header \"dropshot-demo-version\": unexpected character 't' while parsing major version number: threeve" +//! } +//! ``` +//! +//! If we provide version 0.9.0, there is no endpoint at `/thing1`, so we get a +//! 404: +//! +//! ```text +//! $ curl -i -H 'dropshot-demo-version: 0.9.0' http://127.0.0.1:12345/thing1 +//! HTTP/1.1 404 Not Found +//! content-type: application/json +//! x-request-id: 0d3d25b8-4c48-43b2-a417-018ebce68870 +//! content-length: 84 +//! date: Thu, 26 Sep 2024 16:55:20 GMT +//! +//! { +//! "request_id": "0d3d25b8-4c48-43b2-a417-018ebce68870", +//! "message": "Not Found" +//! } +//! ``` +//! +//! If we provide version 1.0.0, we get the v1 handler we defined: +//! +//! ```text +//! $ curl -H 'dropshot-demo-version: 1.0.0' http://127.0.0.1:12345/thing1 +//! {"thing1_early":"hello from an early v1"} +//! ``` +//! +//! If we provide version 2.0.0, we get the later version that we defined, with +//! a different response body type: +//! +//! ```text +//! $ curl -H 'dropshot-demo-version: 2.0.0' http://127.0.0.1:12345/thing1 +//! {"thing1_late":"hello from a LATE v1"} +//! ``` + +use dropshot::endpoint; +use dropshot::ApiDescription; +use dropshot::ClientSpecifiesVersionInHeader; +use dropshot::ConfigDropshot; +use dropshot::ConfigLogging; +use dropshot::ConfigLoggingLevel; +use dropshot::HttpError; +use dropshot::HttpResponseOk; +use dropshot::RequestContext; +use dropshot::ServerBuilder; +use dropshot::VersionPolicy; +use http::HeaderName; +use schemars::JsonSchema; +use serde::Serialize; + +#[tokio::main] +async fn main() -> Result<(), String> { + // See dropshot/examples/basic.rs for more details on these pieces. + let config_dropshot = ConfigDropshot { + bind_address: "127.0.0.1:12345".parse().unwrap(), + ..Default::default() + }; + let config_logging = + ConfigLogging::StderrTerminal { level: ConfigLoggingLevel::Debug }; + let log = config_logging + .to_logger("example-basic") + .map_err(|error| format!("failed to create logger: {}", error))?; + + let mut api = ApiDescription::new(); + api.register(v1::get_thing1).unwrap(); + api.register(v2::get_thing1).unwrap(); + api.register(get_thing2).unwrap(); + + // Determine if we're generating the OpenAPI spec or starting the server. + // Skip the first argument because that's just the name of the program. + let args: Vec<_> = std::env::args().skip(1).collect(); + if args.is_empty() { + Err(String::from("expected subcommand: \"run\" or \"openapi\"")) + } else if args[0] == "openapi" { + if args.len() != 2 { + return Err(String::from( + "subcommand \"openapi\": expected exactly one argument", + )); + } + + // Write an OpenAPI spec for the requested version. + let version: semver::Version = + args[1].parse().map_err(|e| format!("expected semver: {}", e))?; + let _ = api + .openapi("Example API with versioning", version) + .write(&mut std::io::stdout()); + Ok(()) + } else if args[0] == "run" { + // Run a versioned server. + let api_context = (); + + // When using API versioning, you must provide a `VersionPolicy` that + // tells Dropshot how to determine what API version is being used for + // each incoming request. + // + // For this example, we use `ClientSpecifiesVersionInHeader` to tell + // Dropshot that, as the name suggests, the client always specifies the + // version using the "dropshot-demo-version" header. We specify a max + // API version of "2.0.0". This is the "current" (latest) API that this + // example server is intended to support. + // + // You can provide your own impl of `DynamicVersionPolicy` that does + // this differently (e.g., filling in a default if the client doesn't + // provide one). See `DynamicVersionPolicy` for details. + let header_name = "dropshot-demo-version" + .parse::() + .map_err(|_| String::from("demo header name was not valid"))?; + let max_version = semver::Version::new(2, 0, 0); + let version_impl = + ClientSpecifiesVersionInHeader::new(header_name, max_version); + let version_policy = VersionPolicy::Dynamic(Box::new(version_impl)); + let server = ServerBuilder::new(api, api_context, log) + .config(config_dropshot) + .version_policy(version_policy) + .start() + .map_err(|error| format!("failed to create server: {}", error))?; + + server.await + } else { + Err(String::from("unknown subcommand")) + } +} + +// HTTP API interface +// +// This API defines several different versions: +// +// - versions prior to 1.0.0 do not contain a `get_thing1` endpoint +// - versions 1.0.0 through 2.0.0 (exclusive) use `v1::get_thing1` +// - versions 2.0.0 and later use `v2::get_thing1` +// +// `get_thing2` appears in all versions. + +mod v1 { + // The contents of this module define endpoints and types used in all v1.x + // versions. + + use super::*; + + #[derive(Serialize, JsonSchema)] + struct Thing1 { + thing1_early: &'static str, + } + + /// Fetch `thing1` + #[endpoint { + method = GET, + path = "/thing1", + versions = "1.0.0".."2.0.0" + }] + pub async fn get_thing1( + _rqctx: RequestContext<()>, + ) -> Result, HttpError> { + Ok(HttpResponseOk(Thing1 { thing1_early: "hello from an early v1" })) + } +} + +mod v2 { + // The contents of this module define endpoints and types used in v2.x and + // later. + + use super::*; + + #[derive(Serialize, JsonSchema)] + struct Thing1 { + thing1_late: &'static str, + } + + /// Fetch `thing1` + #[endpoint { + method = GET, + path = "/thing1", + versions = "2.0.0".. + }] + pub async fn get_thing1( + _rqctx: RequestContext<()>, + ) -> Result, HttpError> { + Ok(HttpResponseOk(Thing1 { thing1_late: "hello from a LATE v1" })) + } +} + +// The following are used in all API versions. The delta for each version is +// proportional to what actually changed in each version. i.e., you don't have +// to repeat the code for all the unchanged endpoints. + +#[derive(Serialize, JsonSchema)] +struct Thing2 { + thing2: &'static str, +} + +/// Fetch `thing2` +#[endpoint { + method = GET, + path = "/thing2", +}] +pub async fn get_thing2( + _rqctx: RequestContext<()>, +) -> Result, HttpError> { + Ok(HttpResponseOk(Thing2 { thing2: "hello from any version" })) +} diff --git a/dropshot/src/api_description.rs b/dropshot/src/api_description.rs index 3f832982..f7f7893a 100644 --- a/dropshot/src/api_description.rs +++ b/dropshot/src/api_description.rs @@ -59,6 +59,7 @@ pub struct ApiEndpoint { pub extension_mode: ExtensionMode, pub visible: bool, pub deprecated: bool, + pub versions: ApiEndpointVersions, } impl<'a, Context: ServerContext> ApiEndpoint { @@ -68,6 +69,7 @@ impl<'a, Context: ServerContext> ApiEndpoint { method: Method, content_type: &'a str, path: &'a str, + versions: ApiEndpointVersions, ) -> Self where HandlerType: HttpHandlerFunc, @@ -93,6 +95,7 @@ impl<'a, Context: ServerContext> ApiEndpoint { extension_mode: func_parameters.extension_mode, visible: true, deprecated: false, + versions, } } @@ -136,7 +139,7 @@ impl<'a> ApiEndpoint { /// type parameters. /// /// ```rust - /// use dropshot::{ApiDescription, ApiEndpoint, HttpError, HttpResponseOk, Query, StubContext}; + /// use dropshot::{ApiDescription, ApiEndpoint, ApiEndpointVersions, HttpError, HttpResponseOk, Query, StubContext}; /// use schemars::JsonSchema; /// use serde::Deserialize; /// @@ -157,6 +160,7 @@ impl<'a> ApiEndpoint { /// http::Method::GET, /// "application/json", /// "/value", + /// ApiEndpointVersions::All, /// ); /// api.register(endpoint).unwrap(); /// ``` @@ -165,6 +169,7 @@ impl<'a> ApiEndpoint { method: Method, content_type: &'a str, path: &'a str, + versions: ApiEndpointVersions, ) -> Self where FuncParams: RequestExtractor + 'static, @@ -190,6 +195,7 @@ impl<'a> ApiEndpoint { extension_mode: func_parameters.extension_mode, visible: true, deprecated: false, + versions, } } } @@ -351,9 +357,9 @@ impl std::fmt::Debug for ApiSchemaGenerator { } } -/// An ApiDescription represents the endpoints and handler functions in your API. -/// Other metadata could also be provided here. This object can be used to -/// generate an OpenAPI spec or to run an HTTP server implementing the API. +/// An ApiDescription represents the endpoints and handler functions in your +/// API. Other metadata could also be provided here. This object can be used +/// to generate an OpenAPI spec or to run an HTTP server implementing the API. pub struct ApiDescription { /// In practice, all the information we need is encoded in the router. router: HttpRouter, @@ -592,32 +598,36 @@ impl ApiDescription { /// [`OpenApiDefinition`] which can be used to specify the contents of the /// definition and select an output format. /// - /// The arguments to this function will be used for the mandatory `title` and - /// `version` properties that the `Info` object in an OpenAPI definition must - /// contain. - pub fn openapi( + /// The arguments to this function will be used for the mandatory `title` + /// and `version` properties that the `Info` object in an OpenAPI definition + /// must contain. + pub fn openapi( &self, - title: S1, - version: S2, + title: S, + version: semver::Version, ) -> OpenApiDefinition where - S1: AsRef, - S2: AsRef, + S: AsRef, { - OpenApiDefinition::new(self, title.as_ref(), version.as_ref()) + OpenApiDefinition::new(self, title.as_ref(), version) } /// Internal routine for constructing the OpenAPI definition describing this /// API in its JSON form. - fn gen_openapi(&self, info: openapiv3::Info) -> openapiv3::OpenAPI { + fn gen_openapi( + &self, + info: openapiv3::Info, + version: &semver::Version, + ) -> openapiv3::OpenAPI { let mut openapi = openapiv3::OpenAPI::default(); openapi.openapi = "3.0.3".to_string(); openapi.info = info; // Gather up the ad hoc tags from endpoints - let endpoint_tags = (&self.router) - .into_iter() + let endpoint_tags = self + .router + .endpoints(Some(version)) .flat_map(|(_, _, endpoint)| { endpoint .tags @@ -657,7 +667,7 @@ impl ApiDescription { let mut definitions = indexmap::IndexMap::::new(); - for (path, method, endpoint) in &self.router { + for (path, method, endpoint) in self.router.endpoints(Some(version)) { if !endpoint.visible { continue; } @@ -1056,6 +1066,196 @@ impl fmt::Display for ApiDescriptionRegisterError { impl std::error::Error for ApiDescriptionRegisterError {} +/// Describes which versions of the API this endpoint is defined for +#[derive(Debug, Eq, PartialEq)] +pub enum ApiEndpointVersions { + /// this endpoint covers all versions of the API + All, + /// this endpoint was introduced in a specific version and is present in all + /// subsequent versions + From(semver::Version), + /// this endpoint was introduced in a specific version and removed in a + /// subsequent version + // We use an extra level of indirection to enforce that the versions here + // are provided in order. + FromUntil(OrderedVersionPair), + /// this endpoint was present in all versions up to (but not including) this + /// specific version + Until(semver::Version), +} + +#[derive(Debug, Eq, PartialEq)] +pub struct OrderedVersionPair { + earliest: semver::Version, + until: semver::Version, +} + +impl ApiEndpointVersions { + pub fn all() -> ApiEndpointVersions { + ApiEndpointVersions::All + } + + pub fn from(v: semver::Version) -> ApiEndpointVersions { + ApiEndpointVersions::From(v) + } + + pub fn until(v: semver::Version) -> ApiEndpointVersions { + ApiEndpointVersions::Until(v) + } + + pub fn from_until( + earliest: semver::Version, + until: semver::Version, + ) -> Result { + if until < earliest { + return Err( + "versions in a from-until version range must be provided \ + in order", + ); + } + + Ok(ApiEndpointVersions::FromUntil(OrderedVersionPair { + earliest, + until, + })) + } + + /// Returns whether the given `version` matches this endpoint version + /// + /// Recall that `ApiEndpointVersions` essentially defines a _range_ of + /// API versions that an API endpoint will appear in. This returns true if + /// `version` is contained in that range. This is used to determine if an + /// endpoint satisfies the requirements for an incoming request. + /// + /// If `version` is `None`, that means that the request doesn't care what + /// version it's getting. `matches()` always returns true in that case. + pub(crate) fn matches(&self, version: Option<&semver::Version>) -> bool { + let Some(version) = version else { + // If there's no version constraint at all, then all versions match. + return true; + }; + + match self { + ApiEndpointVersions::All => true, + ApiEndpointVersions::From(earliest) => version >= earliest, + ApiEndpointVersions::FromUntil(OrderedVersionPair { + earliest, + until, + }) => { + version >= earliest + && (version < until + || (version == until && earliest == until)) + } + ApiEndpointVersions::Until(until) => version < until, + } + } + + /// Returns whether one version range overlaps with another + /// + /// This is used to disallow registering multiple API endpoints where, if + /// given a particular version, it would be ambiguous which endpoint to use. + pub(crate) fn overlaps_with(&self, other: &ApiEndpointVersions) -> bool { + // There must be better ways to do this. You might think: + // + // - `semver` has a `VersionReq`, which represents a range similar to + // our variants. But it does not have a way to programmatically + // construct it and it does not support an "intersection" operator. + // + // - These are basically Rust ranges, right? Yes, but Rust also doesn't + // have a range "intersection" operator. + match (self, other) { + // easy degenerate cases + (ApiEndpointVersions::All, _) => true, + (_, ApiEndpointVersions::All) => true, + (ApiEndpointVersions::From(_), ApiEndpointVersions::From(_)) => { + true + } + (ApiEndpointVersions::Until(_), ApiEndpointVersions::Until(_)) => { + true + } + + // more complicated cases + ( + ApiEndpointVersions::From(earliest), + u @ ApiEndpointVersions::Until(_), + ) => u.matches(Some(&earliest)), + ( + u @ ApiEndpointVersions::Until(_), + ApiEndpointVersions::From(earliest), + ) => u.matches(Some(&earliest)), + + ( + ApiEndpointVersions::From(earliest), + ApiEndpointVersions::FromUntil(OrderedVersionPair { + earliest: _, + until, + }), + ) => earliest < until, + ( + ApiEndpointVersions::FromUntil(OrderedVersionPair { + earliest: _, + until, + }), + ApiEndpointVersions::From(earliest), + ) => earliest < until, + + ( + u @ ApiEndpointVersions::Until(_), + ApiEndpointVersions::FromUntil(OrderedVersionPair { + earliest, + until: _, + }), + ) => u.matches(Some(&earliest)), + ( + ApiEndpointVersions::FromUntil(OrderedVersionPair { + earliest, + until: _, + }), + u @ ApiEndpointVersions::Until(_), + ) => u.matches(Some(&earliest)), + + ( + r1 @ ApiEndpointVersions::FromUntil(OrderedVersionPair { + earliest: earliest1, + until: _, + }), + r2 @ ApiEndpointVersions::FromUntil(OrderedVersionPair { + earliest: earliest2, + until: _, + }), + ) => r1.matches(Some(&earliest2)) || r2.matches(Some(&earliest1)), + } + } +} + +impl slog::Value for ApiEndpointVersions { + fn serialize( + &self, + _record: &slog::Record, + key: slog::Key, + serializer: &mut dyn slog::Serializer, + ) -> slog::Result { + match self { + ApiEndpointVersions::All => serializer.emit_str(key, "all"), + ApiEndpointVersions::From(from) => serializer.emit_arguments( + key, + &format_args!("all starting from {}", from), + ), + ApiEndpointVersions::FromUntil(OrderedVersionPair { + earliest, + until: latest, + }) => serializer.emit_arguments( + key, + &format_args!("from {} to {}", earliest, latest), + ), + ApiEndpointVersions::Until(until) => serializer.emit_arguments( + key, + &format_args!("all ending with {}", until), + ), + } + } +} + /// Returns true iff the schema represents the void schema that matches no data. fn is_empty(schema: &schemars::schema::Schema) -> bool { if let schemars::schema::Schema::Bool(false) = schema { @@ -1120,27 +1320,28 @@ fn is_empty(schema: &schemars::schema::Schema) -> bool { pub struct OpenApiDefinition<'a, Context: ServerContext> { api: &'a ApiDescription, info: openapiv3::Info, + version: semver::Version, } impl<'a, Context: ServerContext> OpenApiDefinition<'a, Context> { fn new( api: &'a ApiDescription, title: &str, - version: &str, + version: semver::Version, ) -> OpenApiDefinition<'a, Context> { let info = openapiv3::Info { title: title.to_string(), version: version.to_string(), ..Default::default() }; - OpenApiDefinition { api, info } + OpenApiDefinition { api, info, version } } /// Provide a short description of the API. CommonMark syntax may be /// used for rich text representation. /// - /// This routine will set the `description` field of the `Info` object in the - /// OpenAPI definition. + /// This routine will set the `description` field of the `Info` object in + /// the OpenAPI definition. pub fn description>(&mut self, description: S) -> &mut Self { self.info.description = Some(description.as_ref().to_string()); self @@ -1227,7 +1428,9 @@ impl<'a, Context: ServerContext> OpenApiDefinition<'a, Context> { /// Build a JSON object containing the OpenAPI definition for this API. pub fn json(&self) -> serde_json::Result { - serde_json::to_value(&self.api.gen_openapi(self.info.clone())) + serde_json::to_value( + &self.api.gen_openapi(self.info.clone(), &self.version), + ) } /// Build a JSON object containing the OpenAPI definition for this API and @@ -1238,7 +1441,7 @@ impl<'a, Context: ServerContext> OpenApiDefinition<'a, Context> { ) -> serde_json::Result<()> { serde_json::to_writer_pretty( &mut *out, - &self.api.gen_openapi(self.info.clone()), + &self.api.gen_openapi(self.info.clone(), &self.version), )?; writeln!(out).map_err(serde_json::Error::custom)?; Ok(()) @@ -1328,6 +1531,8 @@ mod test { use std::str::from_utf8; use crate as dropshot; // for "endpoint" macro + use crate::api_description::ApiEndpointVersions; + use semver::Version; #[derive(Deserialize, JsonSchema)] #[allow(dead_code)] @@ -1352,6 +1557,7 @@ mod test { Method::GET, CONTENT_TYPE_JSON, "/", + ApiEndpointVersions::All, )); let error = ret.unwrap_err(); assert_eq!( @@ -1369,6 +1575,7 @@ mod test { Method::GET, CONTENT_TYPE_JSON, "/{a}/{aa}/{b}/{bb}", + ApiEndpointVersions::All, )); let error = ret.unwrap_err(); assert_eq!(error.message(), "path parameters are not consumed (aa,bb)"); @@ -1383,6 +1590,7 @@ mod test { Method::GET, CONTENT_TYPE_JSON, "/{c}/{d}", + ApiEndpointVersions::All, )); let error = ret.unwrap_err(); assert_eq!( @@ -1455,6 +1663,7 @@ mod test { Method::GET, CONTENT_TYPE_JSON, "/{a}/{b}", + ApiEndpointVersions::All, )); let error = ret.unwrap_err(); assert_eq!(error.message(), "At least one tag is required".to_string()); @@ -1474,6 +1683,7 @@ mod test { Method::GET, CONTENT_TYPE_JSON, "/{a}/{b}", + ApiEndpointVersions::All, ) .tag("howdy") .tag("pardner"), @@ -1496,6 +1706,7 @@ mod test { Method::GET, CONTENT_TYPE_JSON, "/{a}/{b}", + ApiEndpointVersions::All, ) .tag("a-tag"), ); @@ -1525,6 +1736,7 @@ mod test { Method::GET, CONTENT_TYPE_JSON, "/xx/{a}/{b}", + ApiEndpointVersions::All, ) .tag("a-tag") .tag("z-tag"), @@ -1537,6 +1749,7 @@ mod test { Method::GET, CONTENT_TYPE_JSON, "/yy/{a}/{b}", + ApiEndpointVersions::All, ) .tag("b-tag") .tag("y-tag"), @@ -1544,7 +1757,7 @@ mod test { .unwrap(); let mut out = Vec::new(); - api.openapi("", "").write(&mut out).unwrap(); + api.openapi("", Version::new(1, 0, 0)).write(&mut out).unwrap(); let out = from_utf8(&out).unwrap(); let spec = serde_json::from_str::(out).unwrap(); @@ -1576,4 +1789,278 @@ mod test { assert_eq!(config.policy, EndpointTagPolicy::AtLeastOne); assert_eq!(config.tags.len(), 2); } + + #[test] + fn test_endpoint_versions_range() { + let error = ApiEndpointVersions::from_until( + Version::new(1, 2, 3), + Version::new(1, 2, 2), + ) + .unwrap_err(); + assert_eq!( + error, + "versions in a from-until version range must be provided in order" + ); + } + + #[test] + fn test_endpoint_versions_matches() { + let v_all = ApiEndpointVersions::all(); + let v_from = ApiEndpointVersions::from(Version::new(1, 2, 3)); + let v_until = ApiEndpointVersions::until(Version::new(4, 5, 6)); + let v_fromuntil = ApiEndpointVersions::from_until( + Version::new(1, 2, 3), + Version::new(4, 5, 6), + ) + .unwrap(); + let v_oneversion = ApiEndpointVersions::from_until( + Version::new(1, 2, 3), + Version::new(1, 2, 3), + ) + .unwrap(); + + struct TestCase<'a> { + versions: &'a ApiEndpointVersions, + check: Option, + expected: bool, + } + impl<'a> TestCase<'a> { + fn new( + versions: &'a ApiEndpointVersions, + check: Version, + expected: bool, + ) -> TestCase<'a> { + TestCase { versions, check: Some(check), expected } + } + + fn new_empty(versions: &'a ApiEndpointVersions) -> TestCase<'a> { + // Every type of ApiEndpointVersions ought to match when + // provided no version constraint. + TestCase { versions, check: None, expected: true } + } + } + + let mut nerrors = 0; + for test_case in &[ + TestCase::new_empty(&v_all), + TestCase::new_empty(&v_from), + TestCase::new_empty(&v_until), + TestCase::new_empty(&v_fromuntil), + TestCase::new_empty(&v_oneversion), + TestCase::new(&v_all, Version::new(0, 0, 0), true), + TestCase::new(&v_all, Version::new(1, 0, 0), true), + TestCase::new(&v_all, Version::new(1, 2, 3), true), + TestCase::new(&v_from, Version::new(0, 0, 0), false), + TestCase::new(&v_from, Version::new(1, 2, 2), false), + TestCase::new(&v_from, Version::new(1, 2, 3), true), + TestCase::new(&v_from, Version::new(1, 2, 4), true), + TestCase::new(&v_from, Version::new(5, 0, 0), true), + TestCase::new(&v_until, Version::new(0, 0, 0), true), + TestCase::new(&v_until, Version::new(4, 5, 5), true), + TestCase::new(&v_until, Version::new(4, 5, 6), false), + TestCase::new(&v_until, Version::new(4, 5, 7), false), + TestCase::new(&v_until, Version::new(37, 0, 0), false), + TestCase::new(&v_fromuntil, Version::new(0, 0, 0), false), + TestCase::new(&v_fromuntil, Version::new(1, 2, 2), false), + TestCase::new(&v_fromuntil, Version::new(1, 2, 3), true), + TestCase::new(&v_fromuntil, Version::new(4, 5, 5), true), + TestCase::new(&v_fromuntil, Version::new(4, 5, 6), false), + TestCase::new(&v_fromuntil, Version::new(4, 5, 7), false), + TestCase::new(&v_fromuntil, Version::new(12, 0, 0), false), + TestCase::new(&v_oneversion, Version::new(1, 2, 2), false), + TestCase::new(&v_oneversion, Version::new(1, 2, 3), true), + TestCase::new(&v_oneversion, Version::new(1, 2, 4), false), + ] { + print!( + "test case: {:?} matches {}: expected {}, got ", + test_case.versions, + match &test_case.check { + Some(x) => format!("Some({x})"), + None => String::from("None"), + }, + test_case.expected + ); + + let result = test_case.versions.matches(test_case.check.as_ref()); + if result != test_case.expected { + println!("{} (FAIL)", result); + nerrors += 1; + } else { + println!("{} (PASS)", result); + } + } + + if nerrors > 0 { + panic!("test cases failed: {}", nerrors); + } + } + + #[test] + fn test_endpoint_versions_overlaps() { + let v_all = ApiEndpointVersions::all(); + let v_from = ApiEndpointVersions::from(Version::new(1, 2, 3)); + let v_until = ApiEndpointVersions::until(Version::new(4, 5, 6)); + let v_fromuntil = ApiEndpointVersions::from_until( + Version::new(1, 2, 3), + Version::new(4, 5, 6), + ) + .unwrap(); + let v_oneversion = ApiEndpointVersions::from_until( + Version::new(1, 2, 3), + Version::new(1, 2, 3), + ) + .unwrap(); + + struct TestCase<'a> { + v1: &'a ApiEndpointVersions, + v2: &'a ApiEndpointVersions, + expected: bool, + } + + impl<'a> TestCase<'a> { + fn new( + v1: &'a ApiEndpointVersions, + v2: &'a ApiEndpointVersions, + expected: bool, + ) -> TestCase<'a> { + TestCase { v1, v2, expected } + } + } + + let mut nerrors = 0; + for test_case in &[ + // All of our canned intervals overlap with themselves. + TestCase::new(&v_all, &v_all, true), + TestCase::new(&v_from, &v_from, true), + TestCase::new(&v_until, &v_until, true), + TestCase::new(&v_fromuntil, &v_fromuntil, true), + TestCase::new(&v_oneversion, &v_oneversion, true), + // + // "all" test cases. + // + // "all" overlaps with all of our other canned intervals. + TestCase::new(&v_all, &v_from, true), + TestCase::new(&v_all, &v_until, true), + TestCase::new(&v_all, &v_fromuntil, true), + TestCase::new(&v_all, &v_oneversion, true), + // + // "from" test cases. + // + // "from" + "from" always overlap + TestCase::new( + &v_from, + &ApiEndpointVersions::from(Version::new(0, 1, 2)), + true, + ), + // "from" + "until": overlap is exactly one point + TestCase::new( + &v_from, + &ApiEndpointVersions::until(Version::new(1, 2, 4)), + true, + ), + // "from" + "until": no overlap (right on the edge) + TestCase::new( + &v_from, + &ApiEndpointVersions::until(Version::new(1, 2, 3)), + false, + ), + // "from" + "from-until": overlap + TestCase::new(&v_from, &v_fromuntil, true), + // "from" + "from-until": no overlap + TestCase::new( + &v_from, + &ApiEndpointVersions::from_until( + Version::new(1, 2, 0), + Version::new(1, 2, 3), + ) + .unwrap(), + false, + ), + // + // "until" test cases + // + // "until" + "until" always overlap. + TestCase::new( + &v_until, + &ApiEndpointVersions::until(Version::new(2, 0, 0)), + true, + ), + // "until" plus "from-until": overlap + TestCase::new(&v_until, &v_fromuntil, true), + // "until" plus "from-until": no overlap + TestCase::new( + &v_until, + &ApiEndpointVersions::from_until( + Version::new(4, 5, 6), + Version::new(6, 0, 0), + ) + .unwrap(), + false, + ), + // + // "from-until" test cases + // + // We've tested everything except two "from-until" ranges. + // first: no overlap + TestCase::new( + &v_fromuntil, + &ApiEndpointVersions::from_until( + Version::new(0, 0, 1), + Version::new(1, 2, 3), + ) + .unwrap(), + false, + ), + // overlap at one endpoint + TestCase::new( + &v_fromuntil, + &ApiEndpointVersions::from_until( + Version::new(0, 0, 1), + Version::new(1, 2, 4), + ) + .unwrap(), + true, + ), + // overlap in the middle somewhere + TestCase::new( + &v_fromuntil, + &ApiEndpointVersions::from_until( + Version::new(0, 0, 1), + Version::new(2, 0, 0), + ) + .unwrap(), + true, + ), + // one contained entirely inside the other + TestCase::new( + &v_fromuntil, + &ApiEndpointVersions::from_until( + Version::new(0, 0, 1), + Version::new(12, 0, 0), + ) + .unwrap(), + true, + ), + ] { + print!( + "test case: {:?} overlaps {:?}: expected {}, got ", + test_case.v1, test_case.v2, test_case.expected + ); + + // Make sure to test both directions. The result should be the + // same. + let result1 = test_case.v1.overlaps_with(&test_case.v2); + let result2 = test_case.v2.overlaps_with(&test_case.v1); + if result1 != test_case.expected || result2 != test_case.expected { + println!("{} {} (FAIL)", result1, result2); + nerrors += 1; + } else { + println!("{} (PASS)", result1); + } + } + + if nerrors > 0 { + panic!("test cases failed: {}", nerrors); + } + } } diff --git a/dropshot/src/lib.rs b/dropshot/src/lib.rs index 96aa20d9..265e226c 100644 --- a/dropshot/src/lib.rs +++ b/dropshot/src/lib.rs @@ -306,6 +306,7 @@ //! // Optional fields //! operation_id = "my_operation" // (default: name of the function) //! tags = [ "all", "your", "OpenAPI", "tags" ], +//! versions = .. //! }] //! ``` //! @@ -316,6 +317,9 @@ //! The tags field is used to categorize API endpoints and only impacts the //! OpenAPI spec output. //! +//! The versions field controls which versions of the API this endpoint appears +//! in. See "API Versioning" for more on this. +//! //! //! ### Function parameters //! @@ -492,7 +496,8 @@ //! # // defining fn main puts the doctest in a module context //! # fn main() { //! let description = project_api_mod::stub_api_description().unwrap(); -//! let mut openapi = description.openapi("Project Server", "1.0.0"); +//! let mut openapi = description +//! .openapi("Project Server", semver::Version::new(1, 0, 0)); //! openapi.write(&mut std::io::stdout().lock()).unwrap(); //! # } //! ``` @@ -686,6 +691,102 @@ //! parameters that are mandatory if `page_token` is not specified (when //! fetching the first page of data). //! +//! ## API Versioning +//! +//! Dropshot servers can host multiple versions of an API. See +//! dropshot/examples/versioning.rs for a complete, working, commented example +//! that uses a client-provided header to determine which API version to use for +//! each incoming request. +//! +//! API versioning basically works like this: +//! +//! 1. When using the `endpoint` macro to define an endpoint, you specify a +//! `versions` field as a range of [semver](https://semver.org/) version +//! strings. This identifies what versions of the API this endpoint +//! implementation appears in. Examples: +//! +//! ```text +//! // introduced in 1.0.0, present in all subsequent versions +//! versions = "1.0.0".. +//! +//! // removed in 2.0.0, present in all previous versions +//! // (not present in 2.0.0 itself) +//! versions = .."2.0.0" +//! +//! // introduced in 1.0.0, removed in 2.0.0 +//! // (present only in all 1.x versions, NOT 2.0.0 or later) +//! versions = "1.0.0".."2.0.0" +//! +//! // present in all versions (the default) +//! versions = .. +//! ``` +//! +//! 2. When constructing the server, you provide [`VersionPolicy::Dynamic`] with +//! your own impl of [`DynamicVersionPolicy`] that tells Dropshot how to +//! determine which API version to use for each request. +//! +//! 3. When a request arrives for a server using `VersionPolicy::Dynamic`, +//! Dropshot uses the provided impl to determine the appropriate API version. +//! Then it routes requests by HTTP method and path (like usual) but only +//! considers endpoints whose version range matches the requested API +//! version. +//! +//! 4. When generating an OpenAPI document for your `ApiDescription`, you must +//! provide a specific version to generate it _for_. It will only include +//! endpoints present in that version and types referenced by those +//! endpoints. +//! +//! It is illegal to register multiple endpoints for the same HTTP method and +//! path with overlapping version ranges. +//! +//! All versioning-related configuration is optional. You can ignore it +//! altogether by simply not specifying `versions` for each endpoint and not +//! providing a `VersionPolicy` for the server (or, equivalently, providing +//! `VersionPolicy::Unversioned`). In this case, the server does not try to +//! determine a version for incoming requests. It routes requests to handlers +//! without considering API versions. +//! +//! It's maybe surprising that this mechanism only talks about versioning +//! endpoints, but usually when we think about API versioning we think about +//! types, especially the input and output types. This works because the +//! endpoint implementation itself specifies the input and output types. Let's +//! look at an example. +//! +//! Suppose you have version 1.0.0 of an API with an endpoint `my_endpoint` with +//! a body parameter `TypedBody`. You want to make a breaking change to +//! the API, creating version 2.0.0 where `MyArg` has a new required field. You +//! still want to support API version 1.0.0. Here's one clean way to do this: +//! +//! 1. Mark the existing `my_endpoint` as removed after 1.0.0: +//! 1. Move the `my_endpoint` function _and_ its input type `MyArg` to a +//! new module called `v1`. (You'd also move its output type here if +//! that's changing.) +//! 2. Change the `endpoint` macro invocation on `my_endpoint` to say +//! `versions = ..1.0.0`. This says that it was removed after 1.0.0. +//! 2. Create a new endpoint that appears in 2.0.0. +//! 1. Create a new module called `v2`. +//! 2. In `v2`, create a new type `MyArg` that looks the way you want it to +//! appear in 2.0.0. (You'd also create new versions of the output +//! types, if those are changing, too). +//! 3. Also in `v2`, create a new `my_endpoint` function that accepts and +//! returns the `v2` new versions of the types. Its `endpoint` macro +//! will say `versions = 2.0.0`. +//! +//! As mentioned above, you will also need to create your server with +//! `VersionPolicy::Dynamic` and specify how Dropshot should determine which +//! version to use for each request. But that's it! Having done this: +//! +//! * If you generate an OpenAPI doc for version 1.0.0, Dropshot will include +//! `v1::my_endpoint` and its types. +//! * If you generate an OpenAPI doc for version 2.0.0, Dropshot will include +//! `v2::my_endpoint` and its types. +//! * If a request comes in for version 1.0.0, Dropshot will route it to +//! `v1::my_endpoint` and so parse the body as `v1::MyArg`. +//! * If a request comes in for version 2.0.0, Dropshot will route it to +//! `v2::my_endpoint` and so parse the body as `v2::MyArg`. +//! +//! To see a completed example of this, see dropshot/examples/versioning.rs. +//! //! ## DTrace probes //! //! Dropshot optionally exposes two DTrace probes, `request_start` and @@ -754,6 +855,7 @@ mod schema_util; mod server; mod to_map; mod type_util; +mod versioning; mod websocket; pub mod test_util; @@ -769,6 +871,7 @@ pub use api_description::ApiEndpointBodyContentType; pub use api_description::ApiEndpointParameter; pub use api_description::ApiEndpointParameterLocation; pub use api_description::ApiEndpointResponse; +pub use api_description::ApiEndpointVersions; pub use api_description::EndpointTagPolicy; pub use api_description::ExtensionMode; pub use api_description::OpenApiDefinition; @@ -831,6 +934,9 @@ pub use server::ServerBuilder; pub use server::ServerContext; pub use server::ShutdownWaitFuture; pub use server::{HttpServer, HttpServerStarter}; +pub use versioning::ClientSpecifiesVersionInHeader; +pub use versioning::DynamicVersionPolicy; +pub use versioning::VersionPolicy; pub use websocket::WebsocketChannelResult; pub use websocket::WebsocketConnection; pub use websocket::WebsocketConnectionRaw; diff --git a/dropshot/src/router.rs b/dropshot/src/router.rs index ed3af536..8f43e2bb 100644 --- a/dropshot/src/router.rs +++ b/dropshot/src/router.rs @@ -4,6 +4,7 @@ use super::error::HttpError; use super::handler::RouteHandler; +use crate::api_description::ApiEndpointVersions; use crate::from_map::MapError; use crate::from_map::MapValue; use crate::server::ServerContext; @@ -12,13 +13,14 @@ use crate::ApiEndpointBodyContentType; use http::Method; use http::StatusCode; use percent_encoding::percent_decode_str; +use semver::Version; use std::collections::BTreeMap; use std::collections::BTreeSet; use std::sync::Arc; -/// `HttpRouter` is a simple data structure for routing incoming HTTP requests to -/// specific handler functions based on the request method and URI path. For -/// examples, see the basic test below. +/// `HttpRouter` is a simple data structure for routing incoming HTTP requests +/// to specific handler functions based on the request method, URI path, and +/// version. For examples, see the basic test below. /// /// Routes are registered and looked up according to a path, like `"/foo/bar"`. /// Paths are split into segments separated by one or more '/' characters. When @@ -56,7 +58,8 @@ use std::sync::Arc; /// * A given path cannot use the same variable name twice. For example, you /// can't register path `"/projects/{id}/instances/{id}"`. /// -/// * A given resource may have at most one handler for a given HTTP method. +/// * A given resource may have at most one handler for a given HTTP method and +/// version. /// /// * The expectation is that during server initialization, /// `HttpRouter::insert()` will be invoked to register a number of route @@ -66,6 +69,9 @@ use std::sync::Arc; pub struct HttpRouter { /// root of the trie root: Box>, + /// indicates whether this router contains any endpoints that are + /// constrained by version + has_versioned_routes: bool, } /// Each node in the tree represents a group of HTTP resources having the same @@ -81,7 +87,7 @@ pub struct HttpRouter { #[derive(Debug)] struct HttpRouterNode { /// Handlers, etc. for each of the HTTP methods defined for this node. - methods: BTreeMap>, + methods: BTreeMap>>, /// Edges linking to child nodes. edges: Option>, } @@ -163,7 +169,7 @@ impl PathSegment { /// Wrapper for a path that's the result of user input i.e. an HTTP query. /// We use this type to avoid confusion with paths used to define routes. -#[derive(Debug)] +#[derive(Debug, Clone, Copy)] pub struct InputPath<'a>(&'a str); impl<'a> From<&'a str> for InputPath<'a> { @@ -224,7 +230,10 @@ impl HttpRouterNode { impl HttpRouter { /// Returns a new `HttpRouter` with no routes configured. pub fn new() -> Self { - HttpRouter { root: Box::new(HttpRouterNode::new()) } + HttpRouter { + root: Box::new(HttpRouterNode::new()), + has_versioned_routes: false, + } } /// Configure a route for HTTP requests based on the HTTP `method` and @@ -386,15 +395,48 @@ impl HttpRouter { } let methodname = method.as_str().to_uppercase(); - if node.methods.contains_key(&methodname) { - panic!( - "URI path \"{}\": attempted to create duplicate route for \ - method \"{}\"", - path, method, - ); + let existing_handlers = + node.methods.entry(methodname.clone()).or_default(); + + for handler in existing_handlers.iter() { + if handler.versions.overlaps_with(&endpoint.versions) { + if handler.versions == endpoint.versions { + panic!( + "URI path \"{}\": attempted to create duplicate route \ + for method \"{}\"", + path, methodname + ); + } else { + panic!( + "URI path \"{}\": attempted to register multiple \ + handlers for method \"{}\" with overlapping version \ + ranges", + path, methodname + ); + } + } } - node.methods.insert(methodname, endpoint); + if endpoint.versions != ApiEndpointVersions::All { + self.has_versioned_routes = true; + } + + existing_handlers.push(endpoint); + } + + /// Returns whether this router contains any routes that are constrained by + /// version + pub fn has_versioned_routes(&self) -> bool { + self.has_versioned_routes + } + + #[cfg(test)] + pub fn lookup_route_unversioned( + &self, + method: &Method, + path: InputPath<'_>, + ) -> Result, HttpError> { + self.lookup_route(method, path, None) } /// Look up the route handler for an HTTP request having method `method` and @@ -407,6 +449,7 @@ impl HttpRouter { &self, method: &Method, path: InputPath<'_>, + version: Option<&Version>, ) -> Result, HttpError> { let all_segments = input_path_to_segments(&path).map_err(|_| { HttpError::for_bad_request( @@ -469,30 +512,64 @@ impl HttpRouter { _ => {} } - // As a somewhat special case, if one requests a node with no handlers - // at all, report a 404. We could probably treat this as a 405 as well. - if node.methods.is_empty() { - return Err(HttpError::for_not_found( - None, - String::from("route has no handlers"), - )); - } - + // First, look for a matching implementation. let methodname = method.as_str().to_uppercase(); - node.methods - .get(&methodname) - .map(|handler| RouterLookupResult { + if let Some(handler) = find_handler_matching_version( + node.methods.get(&methodname).map(|v| v.as_slice()).unwrap_or(&[]), + version, + ) { + return Ok(RouterLookupResult { handler: Arc::clone(&handler.handler), operation_id: handler.operation_id.clone(), variables, body_content_type: handler.body_content_type.clone(), - }) - .ok_or_else(|| { - HttpError::for_status(None, StatusCode::METHOD_NOT_ALLOWED) - }) + }); + } + + // We found no handler matching this path, method name, and version. + // We're going to report a 404 ("Not Found") or 405 ("Method Not + // Allowed"). It's a 405 if there are any handlers matching this path + // and version for a different method. It's a 404 otherwise. + if node.methods.values().any(|handlers| { + find_handler_matching_version(handlers, version).is_some() + }) { + Err(HttpError::for_status(None, StatusCode::METHOD_NOT_ALLOWED)) + } else { + Err(HttpError::for_not_found( + None, + format!( + "route has no handlers for version {}", + match version { + Some(v) => v.to_string(), + None => String::from(""), + } + ), + )) + } + } + + pub fn endpoints<'a>( + &'a self, + version: Option<&'a Version>, + ) -> HttpRouterIter<'a, Context> { + HttpRouterIter::new(self, version) } } +/// Given a list of handlers, return the first one matching the given semver +/// +/// If `version` is `None`, any handler will do. +fn find_handler_matching_version<'a, I, C>( + handlers: I, + version: Option<&Version>, +) -> Option<&'a ApiEndpoint> +where + I: IntoIterator>, + C: ServerContext, +{ + handlers.into_iter().find(|h| h.versions.matches(version)) +} + /// Insert a variable into the set after checking for duplicates. fn insert_var( path: &str, @@ -511,14 +588,6 @@ fn insert_var( varnames.insert(new_varname.clone()); } -impl<'a, Context: ServerContext> IntoIterator for &'a HttpRouter { - type Item = (String, String, &'a ApiEndpoint); - type IntoIter = HttpRouterIter<'a, Context>; - fn into_iter(self) -> Self::IntoIter { - HttpRouterIter::new(self) - } -} - /// Route Interator implementation. We perform a preorder, depth first traversal /// of the tree starting from the root node. For each node, we enumerate the /// methods and then descend into its children (or single child in the case of @@ -531,18 +600,42 @@ pub struct HttpRouterIter<'a, Context: ServerContext> { method: Box)> + 'a>, path: Vec<(PathSegment, Box>)>, + version: Option<&'a Version>, } type PathIter<'a, Context> = dyn Iterator>)> + 'a; +fn iter_handlers_from_node<'a, 'b, 'c, C: ServerContext>( + node: &'a HttpRouterNode, + version: Option<&'b Version>, +) -> Box)> + 'c> +where + 'a: 'c, + 'b: 'c, +{ + Box::new(node.methods.iter().flat_map(move |(m, handlers)| { + handlers.iter().filter_map(move |h| { + if h.versions.matches(version) { + Some((m, h)) + } else { + None + } + }) + })) +} + impl<'a, Context: ServerContext> HttpRouterIter<'a, Context> { - fn new(router: &'a HttpRouter) -> Self { + fn new( + router: &'a HttpRouter, + version: Option<&'a Version>, + ) -> Self { HttpRouterIter { - method: Box::new(router.root.methods.iter()), + method: iter_handlers_from_node(&router.root, version), path: vec![( PathSegment::Literal("".to_string()), HttpRouterIter::iter_node(&router.root), )], + version, } } @@ -606,8 +699,8 @@ impl<'a, Context: ServerContext> Iterator for HttpRouterIter<'a, Context> { match self.method.next() { Some((m, ref e)) => break Some((self.path(), m.clone(), e)), None => { - // We've iterated fully through the method in this node so it's - // time to find the next node. + // We've iterated fully through the method in this node so + // it's time to find the next node. match self.path.last_mut() { None => break None, Some((_, ref mut last)) => match last.next() { @@ -620,7 +713,10 @@ impl<'a, Context: ServerContext> Iterator for HttpRouterIter<'a, Context> { path_component, HttpRouterIter::iter_node(node), )); - self.method = Box::new(node.methods.iter()); + self.method = iter_handlers_from_node( + &node, + self.version, + ); } }, } @@ -731,6 +827,7 @@ mod test { use super::HttpRouter; use super::PathSegment; use crate::api_description::ApiEndpointBodyContentType; + use crate::api_description::ApiEndpointVersions; use crate::from_map::from_map; use crate::router::VariableValue; use crate::ApiEndpoint; @@ -739,6 +836,7 @@ mod test { use http::Method; use http::StatusCode; use hyper::Response; + use semver::Version; use serde::Deserialize; use std::collections::BTreeMap; use std::sync::Arc; @@ -761,6 +859,15 @@ mod test { handler: Arc>, method: Method, path: &str, + ) -> ApiEndpoint<()> { + new_endpoint_versions(handler, method, path, ApiEndpointVersions::All) + } + + fn new_endpoint_versions( + handler: Arc>, + method: Method, + path: &str, + versions: ApiEndpointVersions, ) -> ApiEndpoint<()> { ApiEndpoint { operation_id: "test_handler".to_string(), @@ -776,6 +883,7 @@ mod test { extension_mode: Default::default(), visible: true, deprecated: false, + versions, } } @@ -840,6 +948,45 @@ mod test { router.insert(new_endpoint(new_handler(), Method::GET, "//")); } + #[test] + #[should_panic(expected = "URI path \"/boo\": attempted to create \ + duplicate route for method \"GET\"")] + fn test_duplicate_route_same_version() { + let mut router = HttpRouter::new(); + router.insert(new_endpoint_versions( + new_handler(), + Method::GET, + "/boo", + ApiEndpointVersions::From(Version::new(1, 2, 3)), + )); + router.insert(new_endpoint_versions( + new_handler(), + Method::GET, + "/boo", + ApiEndpointVersions::From(Version::new(1, 2, 3)), + )); + } + + #[test] + #[should_panic(expected = "URI path \"/boo\": attempted to register \ + multiple handlers for method \"GET\" with \ + overlapping version ranges")] + fn test_duplicate_route_overlapping_version() { + let mut router = HttpRouter::new(); + router.insert(new_endpoint_versions( + new_handler(), + Method::GET, + "/boo", + ApiEndpointVersions::From(Version::new(1, 2, 3)), + )); + router.insert(new_endpoint_versions( + new_handler(), + Method::GET, + "/boo", + ApiEndpointVersions::From(Version::new(4, 5, 6)), + )); + } + #[test] #[should_panic(expected = "URI path \"/projects/{id}/insts/{id}\": \ variable name \"id\" is used more than once")] @@ -970,54 +1117,98 @@ mod test { let mut router = HttpRouter::new(); // Check a few initial conditions. - let error = router.lookup_route(&Method::GET, "/".into()).unwrap_err(); + let error = router + .lookup_route_unversioned(&Method::GET, "/".into()) + .unwrap_err(); assert_eq!(error.status_code, StatusCode::NOT_FOUND); - let error = - router.lookup_route(&Method::GET, "////".into()).unwrap_err(); + let error = router + .lookup_route_unversioned(&Method::GET, "////".into()) + .unwrap_err(); assert_eq!(error.status_code, StatusCode::NOT_FOUND); - let error = - router.lookup_route(&Method::GET, "/foo/bar".into()).unwrap_err(); + let error = router + .lookup_route_unversioned(&Method::GET, "/foo/bar".into()) + .unwrap_err(); assert_eq!(error.status_code, StatusCode::NOT_FOUND); let error = router - .lookup_route(&Method::GET, "//foo///bar".into()) + .lookup_route_unversioned(&Method::GET, "//foo///bar".into()) .unwrap_err(); assert_eq!(error.status_code, StatusCode::NOT_FOUND); // Insert a route into the middle of the tree. This will let us look at // parent nodes, sibling nodes, and child nodes. router.insert(new_endpoint(new_handler(), Method::GET, "/foo/bar")); - assert!(router.lookup_route(&Method::GET, "/foo/bar".into()).is_ok()); - assert!(router.lookup_route(&Method::GET, "/foo/bar/".into()).is_ok()); - assert!(router.lookup_route(&Method::GET, "//foo/bar".into()).is_ok()); - assert!(router.lookup_route(&Method::GET, "//foo//bar".into()).is_ok()); assert!(router - .lookup_route(&Method::GET, "//foo//bar//".into()) + .lookup_route_unversioned(&Method::GET, "/foo/bar".into()) .is_ok()); assert!(router - .lookup_route(&Method::GET, "///foo///bar///".into()) + .lookup_route_unversioned(&Method::GET, "/foo/bar/".into()) + .is_ok()); + assert!(router + .lookup_route_unversioned(&Method::GET, "//foo/bar".into()) + .is_ok()); + assert!(router + .lookup_route_unversioned(&Method::GET, "//foo//bar".into()) + .is_ok()); + assert!(router + .lookup_route_unversioned(&Method::GET, "//foo//bar//".into()) + .is_ok()); + assert!(router + .lookup_route_unversioned(&Method::GET, "///foo///bar///".into()) .is_ok()); // TODO-cleanup: consider having a "build" step that constructs a // read-only router and does validation like making sure that there's a // GET route on all nodes? - let error = router.lookup_route(&Method::GET, "/".into()).unwrap_err(); + let error = router + .lookup_route_unversioned(&Method::GET, "/".into()) + .unwrap_err(); assert_eq!(error.status_code, StatusCode::NOT_FOUND); - let error = - router.lookup_route(&Method::GET, "/foo".into()).unwrap_err(); + let error = router + .lookup_route_unversioned(&Method::GET, "/foo".into()) + .unwrap_err(); assert_eq!(error.status_code, StatusCode::NOT_FOUND); - let error = - router.lookup_route(&Method::GET, "//foo".into()).unwrap_err(); + let error = router + .lookup_route_unversioned(&Method::GET, "//foo".into()) + .unwrap_err(); assert_eq!(error.status_code, StatusCode::NOT_FOUND); let error = router - .lookup_route(&Method::GET, "/foo/bar/baz".into()) + .lookup_route_unversioned(&Method::GET, "/foo/bar/baz".into()) .unwrap_err(); assert_eq!(error.status_code, StatusCode::NOT_FOUND); - let error = - router.lookup_route(&Method::PUT, "/foo/bar".into()).unwrap_err(); + let error = router + .lookup_route_unversioned(&Method::PUT, "/foo/bar".into()) + .unwrap_err(); assert_eq!(error.status_code, StatusCode::METHOD_NOT_ALLOWED); - let error = - router.lookup_route(&Method::PUT, "/foo/bar/".into()).unwrap_err(); + let error = router + .lookup_route_unversioned(&Method::PUT, "/foo/bar/".into()) + .unwrap_err(); + assert_eq!(error.status_code, StatusCode::METHOD_NOT_ALLOWED); + + // Check error cases that are specific (or handled differently) when + // routes are versioned. + let mut router = HttpRouter::new(); + router.insert(new_endpoint_versions( + new_handler(), + Method::GET, + "/foo", + ApiEndpointVersions::from(Version::new(1, 0, 0)), + )); + let error = router + .lookup_route( + &Method::GET, + "/foo".into(), + Some(&Version::new(0, 9, 0)), + ) + .unwrap_err(); + assert_eq!(error.status_code, StatusCode::NOT_FOUND); + let error = router + .lookup_route( + &Method::PUT, + "/foo".into(), + Some(&Version::new(1, 1, 0)), + ) + .unwrap_err(); assert_eq!(error.status_code, StatusCode::METHOD_NOT_ALLOWED); } @@ -1029,15 +1220,21 @@ mod test { // back, even if we use different names that normalize to "/". // Before we start, sanity-check that there's nothing at the root // already. Other test cases examine the errors in more detail. - assert!(router.lookup_route(&Method::GET, "/".into()).is_err()); + assert!(router + .lookup_route_unversioned(&Method::GET, "/".into()) + .is_err()); router.insert(new_endpoint(new_handler_named("h1"), Method::GET, "/")); - let result = router.lookup_route(&Method::GET, "/".into()).unwrap(); + let result = + router.lookup_route_unversioned(&Method::GET, "/".into()).unwrap(); assert_eq!(result.handler.label(), "h1"); assert!(result.variables.is_empty()); - let result = router.lookup_route(&Method::GET, "//".into()).unwrap(); + let result = + router.lookup_route_unversioned(&Method::GET, "//".into()).unwrap(); assert_eq!(result.handler.label(), "h1"); assert!(result.variables.is_empty()); - let result = router.lookup_route(&Method::GET, "///".into()).unwrap(); + let result = router + .lookup_route_unversioned(&Method::GET, "///".into()) + .unwrap(); assert_eq!(result.handler.label(), "h1"); assert!(result.variables.is_empty()); @@ -1045,49 +1242,149 @@ mod test { // we get both this handler and the previous one if we ask for the // corresponding method and that we get no handler for a different, // third method. - assert!(router.lookup_route(&Method::PUT, "/".into()).is_err()); + assert!(router + .lookup_route_unversioned(&Method::PUT, "/".into()) + .is_err()); router.insert(new_endpoint(new_handler_named("h2"), Method::PUT, "/")); - let result = router.lookup_route(&Method::PUT, "/".into()).unwrap(); + let result = + router.lookup_route_unversioned(&Method::PUT, "/".into()).unwrap(); assert_eq!(result.handler.label(), "h2"); assert!(result.variables.is_empty()); - let result = router.lookup_route(&Method::GET, "/".into()).unwrap(); + let result = + router.lookup_route_unversioned(&Method::GET, "/".into()).unwrap(); assert_eq!(result.handler.label(), "h1"); - assert!(router.lookup_route(&Method::DELETE, "/".into()).is_err()); + assert!(router + .lookup_route_unversioned(&Method::DELETE, "/".into()) + .is_err()); assert!(result.variables.is_empty()); // Now insert a handler one level deeper. Verify that all the previous // handlers behave as we expect, and that we have one handler at the new // path, whichever name we use for it. - assert!(router.lookup_route(&Method::GET, "/foo".into()).is_err()); + assert!(router + .lookup_route_unversioned(&Method::GET, "/foo".into()) + .is_err()); router.insert(new_endpoint( new_handler_named("h3"), Method::GET, "/foo", )); - let result = router.lookup_route(&Method::PUT, "/".into()).unwrap(); + let result = + router.lookup_route_unversioned(&Method::PUT, "/".into()).unwrap(); assert_eq!(result.handler.label(), "h2"); assert!(result.variables.is_empty()); - let result = router.lookup_route(&Method::GET, "/".into()).unwrap(); + let result = + router.lookup_route_unversioned(&Method::GET, "/".into()).unwrap(); assert_eq!(result.handler.label(), "h1"); assert!(result.variables.is_empty()); - let result = router.lookup_route(&Method::GET, "/foo".into()).unwrap(); + let result = router + .lookup_route_unversioned(&Method::GET, "/foo".into()) + .unwrap(); assert_eq!(result.handler.label(), "h3"); assert!(result.variables.is_empty()); - let result = router.lookup_route(&Method::GET, "/foo/".into()).unwrap(); + let result = router + .lookup_route_unversioned(&Method::GET, "/foo/".into()) + .unwrap(); assert_eq!(result.handler.label(), "h3"); assert!(result.variables.is_empty()); - let result = - router.lookup_route(&Method::GET, "//foo//".into()).unwrap(); + let result = router + .lookup_route_unversioned(&Method::GET, "//foo//".into()) + .unwrap(); assert_eq!(result.handler.label(), "h3"); assert!(result.variables.is_empty()); - let result = - router.lookup_route(&Method::GET, "/foo//".into()).unwrap(); + let result = router + .lookup_route_unversioned(&Method::GET, "/foo//".into()) + .unwrap(); assert_eq!(result.handler.label(), "h3"); assert!(result.variables.is_empty()); - assert!(router.lookup_route(&Method::PUT, "/foo".into()).is_err()); - assert!(router.lookup_route(&Method::PUT, "/foo/".into()).is_err()); - assert!(router.lookup_route(&Method::PUT, "//foo//".into()).is_err()); - assert!(router.lookup_route(&Method::PUT, "/foo//".into()).is_err()); + assert!(router + .lookup_route_unversioned(&Method::PUT, "/foo".into()) + .is_err()); + assert!(router + .lookup_route_unversioned(&Method::PUT, "/foo/".into()) + .is_err()); + assert!(router + .lookup_route_unversioned(&Method::PUT, "//foo//".into()) + .is_err()); + assert!(router + .lookup_route_unversioned(&Method::PUT, "/foo//".into()) + .is_err()); + } + + #[test] + fn test_router_versioned() { + // Install handlers for a particular route for a bunch of different + // versions. + // + // This is not exhaustive because the matching logic is tested + // exhaustively elsewhere. + let method = Method::GET; + let path: super::InputPath<'static> = "/foo".into(); + let mut router = HttpRouter::new(); + router.insert(new_endpoint_versions( + new_handler_named("h1"), + method.clone(), + "/foo", + ApiEndpointVersions::until(Version::new(1, 2, 3)), + )); + router.insert(new_endpoint_versions( + new_handler_named("h2"), + method.clone(), + "/foo", + ApiEndpointVersions::from_until( + Version::new(2, 0, 0), + Version::new(3, 0, 0), + ) + .unwrap(), + )); + router.insert(new_endpoint_versions( + new_handler_named("h3"), + method.clone(), + "/foo", + ApiEndpointVersions::From(Version::new(5, 0, 0)), + )); + + // Check what happens for a representative range of versions. + let result = router + .lookup_route(&method, path, Some(&Version::new(1, 2, 2))) + .unwrap(); + assert_eq!(result.handler.label(), "h1"); + let error = router + .lookup_route(&method, path, Some(&Version::new(1, 2, 3))) + .unwrap_err(); + assert_eq!(error.status_code, StatusCode::NOT_FOUND); + + let result = router + .lookup_route(&method, path, Some(&Version::new(2, 0, 0))) + .unwrap(); + assert_eq!(result.handler.label(), "h2"); + let result = router + .lookup_route(&method, path, Some(&Version::new(2, 1, 0))) + .unwrap(); + assert_eq!(result.handler.label(), "h2"); + + let error = router + .lookup_route(&method, path, Some(&Version::new(3, 0, 0))) + .unwrap_err(); + assert_eq!(error.status_code, StatusCode::NOT_FOUND); + let error = router + .lookup_route(&method, path, Some(&Version::new(3, 0, 1))) + .unwrap_err(); + assert_eq!(error.status_code, StatusCode::NOT_FOUND); + + let error = router + .lookup_route(&method, path, Some(&Version::new(4, 99, 99))) + .unwrap_err(); + assert_eq!(error.status_code, StatusCode::NOT_FOUND); + + let result = router + .lookup_route(&method, path, Some(&Version::new(5, 0, 0))) + .unwrap(); + assert_eq!(result.handler.label(), "h3"); + let result = router + .lookup_route(&method, path, Some(&Version::new(128313, 0, 0))) + .unwrap(); + assert_eq!(result.handler.label(), "h3"); } #[test] @@ -1096,7 +1393,7 @@ mod test { // change the behavior, intentionally or otherwise. let mut router = HttpRouter::new(); assert!(router - .lookup_route(&Method::GET, "/not{a}variable".into()) + .lookup_route_unversioned(&Method::GET, "/not{a}variable".into()) .is_err()); router.insert(new_endpoint( new_handler_named("h4"), @@ -1104,15 +1401,15 @@ mod test { "/not{a}variable", )); let result = router - .lookup_route(&Method::GET, "/not{a}variable".into()) + .lookup_route_unversioned(&Method::GET, "/not{a}variable".into()) .unwrap(); assert_eq!(result.handler.label(), "h4"); assert!(result.variables.is_empty()); assert!(router - .lookup_route(&Method::GET, "/not{b}variable".into()) + .lookup_route_unversioned(&Method::GET, "/not{b}variable".into()) .is_err()); assert!(router - .lookup_route(&Method::GET, "/notnotavariable".into()) + .lookup_route_unversioned(&Method::GET, "/notnotavariable".into()) .is_err()); } @@ -1125,12 +1422,14 @@ mod test { Method::GET, "/projects/{project_id}", )); - assert!(router.lookup_route(&Method::GET, "/projects".into()).is_err()); assert!(router - .lookup_route(&Method::GET, "/projects/".into()) + .lookup_route_unversioned(&Method::GET, "/projects".into()) + .is_err()); + assert!(router + .lookup_route_unversioned(&Method::GET, "/projects/".into()) .is_err()); let result = router - .lookup_route(&Method::GET, "/projects/p12345".into()) + .lookup_route_unversioned(&Method::GET, "/projects/p12345".into()) .unwrap(); assert_eq!(result.handler.label(), "h5"); assert_eq!( @@ -1142,10 +1441,13 @@ mod test { VariableValue::String("p12345".to_string()) ); assert!(router - .lookup_route(&Method::GET, "/projects/p12345/child".into()) + .lookup_route_unversioned( + &Method::GET, + "/projects/p12345/child".into() + ) .is_err()); let result = router - .lookup_route(&Method::GET, "/projects/p12345/".into()) + .lookup_route_unversioned(&Method::GET, "/projects/p12345/".into()) .unwrap(); assert_eq!(result.handler.label(), "h5"); assert_eq!( @@ -1153,7 +1455,10 @@ mod test { VariableValue::String("p12345".to_string()) ); let result = router - .lookup_route(&Method::GET, "/projects///p12345//".into()) + .lookup_route_unversioned( + &Method::GET, + "/projects///p12345//".into(), + ) .unwrap(); assert_eq!(result.handler.label(), "h5"); assert_eq!( @@ -1162,7 +1467,10 @@ mod test { ); // Trick question! let result = router - .lookup_route(&Method::GET, "/projects/{project_id}".into()) + .lookup_route_unversioned( + &Method::GET, + "/projects/{project_id}".into(), + ) .unwrap(); assert_eq!(result.handler.label(), "h5"); assert_eq!( @@ -1182,7 +1490,7 @@ mod test { {fwrule_id}/info", )); let result = router - .lookup_route( + .lookup_route_unversioned( &Method::GET, "/projects/p1/instances/i2/fwrules/fw3/info".into(), ) @@ -1217,16 +1525,28 @@ mod test { "/projects/{project_id}/instances", )); assert!(router - .lookup_route(&Method::GET, "/projects/instances".into()) + .lookup_route_unversioned( + &Method::GET, + "/projects/instances".into() + ) .is_err()); assert!(router - .lookup_route(&Method::GET, "/projects//instances".into()) + .lookup_route_unversioned( + &Method::GET, + "/projects//instances".into() + ) .is_err()); assert!(router - .lookup_route(&Method::GET, "/projects///instances".into()) + .lookup_route_unversioned( + &Method::GET, + "/projects///instances".into() + ) .is_err()); let result = router - .lookup_route(&Method::GET, "/projects/foo/instances".into()) + .lookup_route_unversioned( + &Method::GET, + "/projects/foo/instances".into(), + ) .unwrap(); assert_eq!(result.handler.label(), "h7"); } @@ -1241,7 +1561,10 @@ mod test { )); let result = router - .lookup_route(&Method::OPTIONS, "/console/missiles/launch".into()) + .lookup_route_unversioned( + &Method::OPTIONS, + "/console/missiles/launch".into(), + ) .unwrap(); assert_eq!( @@ -1274,7 +1597,10 @@ mod test { )); let result = router - .lookup_route(&Method::OPTIONS, "/console/missiles/launch".into()) + .lookup_route_unversioned( + &Method::OPTIONS, + "/console/missiles/launch".into(), + ) .unwrap(); let path = @@ -1288,7 +1614,7 @@ mod test { #[test] fn test_iter_null() { let router = HttpRouter::<()>::new(); - let ret: Vec<_> = router.into_iter().map(|x| (x.0, x.1)).collect(); + let ret: Vec<_> = router.endpoints(None).map(|x| (x.0, x.1)).collect(); assert_eq!(ret, vec![]); } @@ -1305,7 +1631,7 @@ mod test { Method::GET, "/projects/{project_id}/instances", )); - let ret: Vec<_> = router.into_iter().map(|x| (x.0, x.1)).collect(); + let ret: Vec<_> = router.endpoints(None).map(|x| (x.0, x.1)).collect(); assert_eq!( ret, vec![ @@ -1331,7 +1657,7 @@ mod test { Method::POST, "/", )); - let ret: Vec<_> = router.into_iter().map(|x| (x.0, x.1)).collect(); + let ret: Vec<_> = router.endpoints(None).map(|x| (x.0, x.1)).collect(); assert_eq!( ret, vec![ diff --git a/dropshot/src/server.rs b/dropshot/src/server.rs index ab6135bb..c47e8a8f 100644 --- a/dropshot/src/server.rs +++ b/dropshot/src/server.rs @@ -10,6 +10,7 @@ use super::error::HttpError; use super::handler::RequestContext; use super::http_util::HEADER_REQUEST_ID; use super::router::HttpRouter; +use super::versioning::VersionPolicy; use super::ProbeRegistration; use async_stream::stream; @@ -45,7 +46,7 @@ use crate::RequestInfo; use slog::Logger; use thiserror::Error; -// TODO Replace this with something else? +// TODO Remove when we can remove `HttpServerStarter` type GenericError = Box; /// Endpoint-accessible context associated with a server. @@ -73,6 +74,8 @@ pub struct DropshotState { /// Worker for the handler_waitgroup associated with this server, allowing /// graceful shutdown to wait for all handlers to complete. pub(crate) handler_waitgroup_worker: DebugIgnore, + /// specifies how incoming requests are mapped to handlers based on versions + pub(crate) version_policy: VersionPolicy, } impl DropshotState { @@ -154,6 +157,7 @@ impl HttpServerStarter { private: C, log: &Logger, tls: Option, + version_policy: VersionPolicy, ) -> Result, BuildError> { let tcp = { let std_listener = std::net::TcpListener::bind( @@ -194,20 +198,30 @@ impl HttpServerStarter { }) .transpose()?; let handler_waitgroup = WaitGroup::new(); + + let router = api.into_router(); + if let VersionPolicy::Unversioned = version_policy { + if router.has_versioned_routes() { + return Err(BuildError::UnversionedServerHasVersionedRoutes); + } + } + let app_state = Arc::new(DropshotState { private, config: server_config, - router: api.into_router(), + router, log: log.clone(), local_addr, tls_acceptor: tls_acceptor.clone(), handler_waitgroup_worker: DebugIgnore(handler_waitgroup.worker()), + version_policy, }); - for (path, method, _) in &app_state.router { + for (path, method, endpoint) in app_state.router.endpoints(None) { debug!(&log, "registered endpoint"; "method" => &method, - "path" => &path + "path" => &path, + "versions" => &endpoint.versions, ); } @@ -885,8 +899,13 @@ async fn http_request_handle( let request = request.map(crate::Body::wrap); let method = request.method(); let uri = request.uri(); - let lookup_result = - server.router.lookup_route(&method, uri.path().into())?; + let found_version = + server.version_policy.request_version(&request, &request_log)?; + let lookup_result = server.router.lookup_route( + &method, + uri.path().into(), + found_version.as_ref(), + )?; let rqctx = RequestContext { server: Arc::clone(&server), request: RequestInfo::new(&request, remote_addr), @@ -1052,16 +1071,16 @@ pub enum BuildError { }, #[error("expected exactly one TLS private key")] NotOnePrivateKey, - #[error("must register an API")] - MissingApi, - #[error("only one API can be registered with a server")] - TooManyApis, #[error("{context}")] SystemError { context: String, #[source] error: std::io::Error, }, + #[error( + "unversioned servers cannot have endpoints with specific versions" + )] + UnversionedServerHasVersionedRoutes, } impl BuildError { @@ -1092,6 +1111,7 @@ pub struct ServerBuilder { // optional caller-provided values config: ConfigDropshot, + version_policy: VersionPolicy, tls: Option, } @@ -1112,6 +1132,7 @@ impl ServerBuilder { log, api: DebugIgnore(api), config: Default::default(), + version_policy: VersionPolicy::Unversioned, tls: Default::default(), } } @@ -1131,7 +1152,21 @@ impl ServerBuilder { self } + /// Specifies whether and how this server determines the API version to use + /// for incoming requests + /// + /// All the interfaces related to [`VersionPolicy`] are considered + /// experimental and may change in an upcoming release. + pub fn version_policy(mut self, version_policy: VersionPolicy) -> Self { + self.version_policy = version_policy; + self + } + /// Start the server + /// + /// # Errors + /// + /// See [`ServerBuilder::build_starter()`]. pub fn start(self) -> Result, BuildError> { Ok(self.build_starter()?.start()) } @@ -1139,6 +1174,18 @@ impl ServerBuilder { /// Build an `HttpServerStarter` that can be used to start the server /// /// Most consumers probably want to use `start()` instead. + /// + /// # Errors + /// + /// This fails if: + /// + /// * We could not bind to the requested IP address and TCP port + /// * The provided `tls` configuration was not valid + /// * The `version_policy` is `VersionPolicy::Unversioned` and `api` (the + /// `ApiDescription`) contains any endpoints that are version-restricted + /// (i.e., have "versions" set to anything other than + /// `ApiEndpointVersions::All)`. Versioned routes are not supported with + /// unversioned servers. pub fn build_starter(self) -> Result, BuildError> { HttpServerStarter::new_internal( &self.config, @@ -1146,6 +1193,7 @@ impl ServerBuilder { self.private, &self.log, self.tls, + self.version_policy, ) } } diff --git a/dropshot/src/versioning.rs b/dropshot/src/versioning.rs new file mode 100644 index 00000000..96a6025d --- /dev/null +++ b/dropshot/src/versioning.rs @@ -0,0 +1,206 @@ +// Copyright 2024 Oxide Computer Company + +//! Support for API versioning + +use crate::Body; +use crate::HttpError; +use http::HeaderName; +use hyper::Request; +use semver::Version; +use slog::Logger; +use std::str::FromStr; + +/// Specifies how a server handles API versioning +#[derive(Debug)] +pub enum VersionPolicy { + /// This server does not use API versioning. + /// + /// All endpoints registered with this server must be specified with + /// versions = `ApiEndpointVersions::All` (the default). Dropshot will not + /// attempt to determine a version for each request. It will route requests + /// without considering versions at all. + Unversioned, + + /// This server uses API versioning and the provided + /// [`DynamicVersionPolicy`] specifies how to determine the API version to + /// use for each incoming request. + /// + /// With this policy, when a request arrives, Dropshot uses the provided + /// `DynamicVersionPolicy` impl to determine what API version to use when + /// handling the request. Then it routes the request to a handler based on + /// the HTTP method and path (as usual), filtering out handlers whose + /// associated `versions` does not include the requested version. + Dynamic(Box), +} + +impl VersionPolicy { + /// Given an incoming request, determine the version constraint (if any) to + /// use when routing the request to a handler + pub(crate) fn request_version( + &self, + request: &Request, + request_log: &Logger, + ) -> Result, HttpError> { + match self { + // If the server is unversioned, then we can ignore versioning + // altogether when routing. The result is still ambiguous because + // we never allow multiple endpoints to have the same HTTP method + // and path and overlapping version ranges, and unversioned servers + // only support endpoints whose version range is `All`. + VersionPolicy::Unversioned => Ok(None), + + // If the server is versioned, use the client-provided impl to + // determine which version to use. In this case the impl must + // return a value or an error -- it's not allowed to decline to + // provide a version. + VersionPolicy::Dynamic(vers_impl) => { + let result = + vers_impl.request_extract_version(request, request_log); + + match &result { + Ok(version) => { + debug!(request_log, "determined request API version"; + "version" => %version, + ); + } + Err(error) => { + error!( + request_log, + "failed to determine request API version"; + "error" => ?error, + ); + } + } + + result.map(Some) + } + } + } +} + +/// Determines the API version to use for an incoming request +/// +/// See [`ClientSpecifiesVersionInHeader`] for a basic implementation that, as +/// the name suggests, requires that the client specify the exact version they +/// want to use in a header and then always uses whatever they provide. +/// +/// This trait gives you freedom to implement a very wide range of behavior. +/// For example, you could: +/// +/// * Require that the client specify a particular version and always use that +/// * Require that the client specify a particular version but require that it +/// come from a fixed set of supported versions +/// * Allow clients to specify a specific version but supply a default if they +/// don't +/// * Allow clients to specify something else (e.g., a version range, like +/// ">1.0.0") that you then map to a specific version based on the API +/// versions that you know about +/// +/// This does mean that if you care about restricting this in any way (e.g., +/// restricting the allowed API versions to a fixed set), you must implement +/// that yourself by impl'ing this trait. +pub trait DynamicVersionPolicy: std::fmt::Debug + Send + Sync { + /// Given a request, determine the API version to use to route the request + /// to the appropriate handler + /// + /// This is expected to be a quick, synchronous operation. Most commonly, + /// you might parse a semver out of a particular header, maybe match it + /// against some supported set of versions, and maybe supply a default if + /// you don't find the header at all. + fn request_extract_version( + &self, + request: &Request, + log: &Logger, + ) -> Result; +} + +/// Implementation of `DynamicVersionPolicy` where the client must specify a +/// specific semver in a specific header and we always use whatever they +/// requested +/// +/// An incoming request will be rejected with a 400-level error if: +/// +/// - the header value cannot be parsed as a semver, or +/// - the requested version is newer than `max_version` (see +/// [`ClientSpecifiesVersionInHeader::new()`], which implies that the client +/// is trying to use a newer version of the API than this server supports. +/// +/// If you need anything more flexible (e.g., validating the provided version +/// against a fixed set of supported versions), you'll want to impl +/// `DynamicVersionPolicy` yourself. +#[derive(Debug)] +pub struct ClientSpecifiesVersionInHeader { + name: HeaderName, + max_version: Version, +} + +impl ClientSpecifiesVersionInHeader { + /// Make a new `ClientSpecifiesVersionInHeader` policy + /// + /// Arguments: + /// + /// * `name`: name of the header that the client will use to specify the + /// version + /// * `max_version`: the maximum version of the API that this server + /// supports. Requests for a version newer than this will be rejected + /// with a 400-level error. + pub fn new( + name: HeaderName, + max_version: Version, + ) -> ClientSpecifiesVersionInHeader { + ClientSpecifiesVersionInHeader { name, max_version } + } +} + +impl DynamicVersionPolicy for ClientSpecifiesVersionInHeader { + fn request_extract_version( + &self, + request: &Request, + _log: &Logger, + ) -> Result { + let v = parse_header(request.headers(), &self.name)?; + if v <= self.max_version { + Ok(v) + } else { + Err(HttpError::for_bad_request( + None, + format!("server does not support this API version: {}", v), + )) + } + } +} + +/// Parses a required header out of a request (producing useful error messages +/// for all failure modes) +fn parse_header( + headers: &http::HeaderMap, + header_name: &HeaderName, +) -> Result +where + T: FromStr, + ::Err: std::fmt::Display, +{ + let v_value = headers.get(header_name).ok_or_else(|| { + HttpError::for_bad_request( + None, + format!("missing expected header {:?}", header_name), + ) + })?; + + let v_str = v_value.to_str().map_err(|_| { + HttpError::for_bad_request( + None, + format!( + "bad value for header {:?}: not ASCII: {:?}", + header_name, v_value + ), + ) + })?; + + v_str.parse::().map_err(|e| { + HttpError::for_bad_request( + None, + format!("bad value for header {:?}: {}: {}", header_name, e, v_str), + ) + }) +} diff --git a/dropshot/src/websocket.rs b/dropshot/src/websocket.rs index d184e412..1fb4cfec 100644 --- a/dropshot/src/websocket.rs +++ b/dropshot/src/websocket.rs @@ -354,7 +354,7 @@ mod tests { use crate::server::{DropshotState, ServerConfig}; use crate::{ ExclusiveExtractor, HttpError, RequestContext, RequestInfo, - WebsocketUpgrade, + VersionPolicy, WebsocketUpgrade, }; use debug_ignore::DebugIgnore; use http::Request; @@ -396,6 +396,7 @@ mod tests { handler_waitgroup_worker: DebugIgnore( WaitGroup::new().worker(), ), + version_policy: VersionPolicy::Unversioned, }), request: RequestInfo::new(&request, remote_addr), path_variables: Default::default(), diff --git a/dropshot/tests/fail/bad_endpoint10.stderr b/dropshot/tests/fail/bad_endpoint10.stderr index 01a065e1..7baafea3 100644 --- a/dropshot/tests/fail/bad_endpoint10.stderr +++ b/dropshot/tests/fail/bad_endpoint10.stderr @@ -13,7 +13,7 @@ note: required by a bound in `validate_result_error_type` 16 | ) -> Result, String> { | ^^^^^^ required by this bound in `validate_result_error_type` -error[E0277]: the trait bound `fn(RequestContext<()>) -> impl Future, String>> { for ApiEndpoint< as RequestContextArgument>::Context>>::from::bad_error_type}: dropshot::handler::HttpHandlerFunc<_, _, _>` is not satisfied +error[E0277]: the trait bound `fn(RequestContext<()>) -> impl Future, String>> { for ApiEndpoint< as RequestContextArgument>::Context>>::from::bad_error_type}: dropshot::handler::HttpHandlerFunc<_, _, _>` is not satisfied --> tests/fail/bad_endpoint10.rs:14:10 | 10 | / #[endpoint { @@ -22,7 +22,7 @@ error[E0277]: the trait bound `fn(RequestContext<()>) -> impl Future` is not implemented for fn item `fn(RequestContext<()>) -> impl Future, String>> { for ApiEndpoint< as RequestContextArgument>::Context>>::from::bad_error_type}` + | ^^^^^^^^^^^^^^ the trait `dropshot::handler::HttpHandlerFunc<_, _, _>` is not implemented for fn item `fn(RequestContext<()>) -> impl Future, String>> { for ApiEndpoint< as RequestContextArgument>::Context>>::from::bad_error_type}` | note: required by a bound in `ApiEndpoint::::new` --> src/api_description.rs diff --git a/dropshot/tests/fail/bad_endpoint12.stderr b/dropshot/tests/fail/bad_endpoint12.stderr index 9e47406d..5e24b92b 100644 --- a/dropshot/tests/fail/bad_endpoint12.stderr +++ b/dropshot/tests/fail/bad_endpoint12.stderr @@ -26,7 +26,7 @@ note: required for `Result` to implement `ResultTrait` 15 | ) -> Result { | ^^^^^^ -error[E0277]: the trait bound `fn(RequestContext<()>) -> impl Future> { for ApiEndpoint< as RequestContextArgument>::Context>>::from::bad_response_type}: dropshot::handler::HttpHandlerFunc<_, _, _>` is not satisfied +error[E0277]: the trait bound `fn(RequestContext<()>) -> impl Future> { for ApiEndpoint< as RequestContextArgument>::Context>>::from::bad_response_type}: dropshot::handler::HttpHandlerFunc<_, _, _>` is not satisfied --> tests/fail/bad_endpoint12.rs:13:10 | 9 | / #[endpoint { @@ -35,7 +35,7 @@ error[E0277]: the trait bound `fn(RequestContext<()>) -> impl Future` is not implemented for fn item `fn(RequestContext<()>) -> impl Future> { for ApiEndpoint< as RequestContextArgument>::Context>>::from::bad_response_type}` + | ^^^^^^^^^^^^^^^^^ the trait `dropshot::handler::HttpHandlerFunc<_, _, _>` is not implemented for fn item `fn(RequestContext<()>) -> impl Future> { for ApiEndpoint< as RequestContextArgument>::Context>>::from::bad_response_type}` | note: required by a bound in `ApiEndpoint::::new` --> src/api_description.rs diff --git a/dropshot/tests/fail/bad_endpoint15.stderr b/dropshot/tests/fail/bad_endpoint15.stderr index 65d7312d..08e8a658 100644 --- a/dropshot/tests/fail/bad_endpoint15.stderr +++ b/dropshot/tests/fail/bad_endpoint15.stderr @@ -1,4 +1,4 @@ -error[E0277]: the trait bound `fn(RequestContext<()>) -> impl Future, HttpError>> { for ApiEndpoint< as RequestContextArgument>::Context>>::from::bad_endpoint}: dropshot::handler::HttpHandlerFunc<_, _, _>` is not satisfied +error[E0277]: the trait bound `fn(RequestContext<()>) -> impl Future, HttpError>> { for ApiEndpoint< as RequestContextArgument>::Context>>::from::bad_endpoint}: dropshot::handler::HttpHandlerFunc<_, _, _>` is not satisfied --> tests/fail/bad_endpoint15.rs:17:10 | 13 | / #[endpoint { @@ -7,7 +7,7 @@ error[E0277]: the trait bound `fn(RequestContext<()>) -> impl Future` is not implemented for fn item `fn(RequestContext<()>) -> impl Future, HttpError>> { for ApiEndpoint< as RequestContextArgument>::Context>>::from::bad_endpoint}` + | ^^^^^^^^^^^^ the trait `dropshot::handler::HttpHandlerFunc<_, _, _>` is not implemented for fn item `fn(RequestContext<()>) -> impl Future, HttpError>> { for ApiEndpoint< as RequestContextArgument>::Context>>::from::bad_endpoint}` | note: required by a bound in `ApiEndpoint::::new` --> src/api_description.rs diff --git a/dropshot/tests/fail/bad_endpoint17.stderr b/dropshot/tests/fail/bad_endpoint17.stderr index a1e3f0d9..4c81848e 100644 --- a/dropshot/tests/fail/bad_endpoint17.stderr +++ b/dropshot/tests/fail/bad_endpoint17.stderr @@ -20,7 +20,7 @@ note: required by a bound in `need_shared_extractor` | --------- required by a bound in this function = note: this error originates in the attribute macro `endpoint` (in Nightly builds, run with -Z macro-backtrace for more info) -error[E0277]: the trait bound `fn(RequestContext<()>, TypedBody, UntypedBody) -> impl Future, HttpError>> { for ApiEndpoint< as RequestContextArgument>::Context>>::from::two_exclusive_extractors}: dropshot::handler::HttpHandlerFunc<_, _, _>` is not satisfied +error[E0277]: the trait bound `fn(RequestContext<()>, TypedBody, UntypedBody) -> impl Future, HttpError>> { for ApiEndpoint< as RequestContextArgument>::Context>>::from::two_exclusive_extractors}: dropshot::handler::HttpHandlerFunc<_, _, _>` is not satisfied --> tests/fail/bad_endpoint17.rs:28:10 | 24 | / #[endpoint { @@ -29,7 +29,7 @@ error[E0277]: the trait bound `fn(RequestContext<()>, TypedBody, UntypedB 27 | | }] | |__- required by a bound introduced by this call 28 | async fn two_exclusive_extractors( - | ^^^^^^^^^^^^^^^^^^^^^^^^ the trait `dropshot::handler::HttpHandlerFunc<_, _, _>` is not implemented for fn item `fn(RequestContext<()>, TypedBody, UntypedBody) -> impl Future, HttpError>> { for ApiEndpoint< as RequestContextArgument>::Context>>::from::two_exclusive_extractors}` + | ^^^^^^^^^^^^^^^^^^^^^^^^ the trait `dropshot::handler::HttpHandlerFunc<_, _, _>` is not implemented for fn item `fn(RequestContext<()>, TypedBody, UntypedBody) -> impl Future, HttpError>> { for ApiEndpoint< as RequestContextArgument>::Context>>::from::two_exclusive_extractors}` | note: required by a bound in `ApiEndpoint::::new` --> src/api_description.rs diff --git a/dropshot/tests/fail/bad_endpoint18.stderr b/dropshot/tests/fail/bad_endpoint18.stderr index a2710aba..d9f0a273 100644 --- a/dropshot/tests/fail/bad_endpoint18.stderr +++ b/dropshot/tests/fail/bad_endpoint18.stderr @@ -20,7 +20,7 @@ note: required by a bound in `need_shared_extractor` | --------- required by a bound in this function = note: this error originates in the attribute macro `endpoint` (in Nightly builds, run with -Z macro-backtrace for more info) -error[E0277]: the trait bound `fn(RequestContext<()>, TypedBody, dropshot::Query) -> impl Future, HttpError>> { for ApiEndpoint< as RequestContextArgument>::Context>>::from::exclusive_extractor_not_last}: dropshot::handler::HttpHandlerFunc<_, _, _>` is not satisfied +error[E0277]: the trait bound `fn(RequestContext<()>, TypedBody, dropshot::Query) -> impl Future, HttpError>> { for ApiEndpoint< as RequestContextArgument>::Context>>::from::exclusive_extractor_not_last}: dropshot::handler::HttpHandlerFunc<_, _, _>` is not satisfied --> tests/fail/bad_endpoint18.rs:25:10 | 21 | / #[endpoint { @@ -29,7 +29,7 @@ error[E0277]: the trait bound `fn(RequestContext<()>, TypedBody, dropshot 24 | | }] | |__- required by a bound introduced by this call 25 | async fn exclusive_extractor_not_last( - | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ the trait `dropshot::handler::HttpHandlerFunc<_, _, _>` is not implemented for fn item `fn(RequestContext<()>, TypedBody, dropshot::Query) -> impl Future, HttpError>> { for ApiEndpoint< as RequestContextArgument>::Context>>::from::exclusive_extractor_not_last}` + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ the trait `dropshot::handler::HttpHandlerFunc<_, _, _>` is not implemented for fn item `fn(RequestContext<()>, TypedBody, dropshot::Query) -> impl Future, HttpError>> { for ApiEndpoint< as RequestContextArgument>::Context>>::from::exclusive_extractor_not_last}` | note: required by a bound in `ApiEndpoint::::new` --> src/api_description.rs diff --git a/dropshot/tests/fail/bad_endpoint19.stderr b/dropshot/tests/fail/bad_endpoint19.stderr index f8bb4bb6..efaf795d 100644 --- a/dropshot/tests/fail/bad_endpoint19.stderr +++ b/dropshot/tests/fail/bad_endpoint19.stderr @@ -20,7 +20,7 @@ note: required by a bound in `need_shared_extractor` | ------ required by a bound in this function = note: this error originates in the attribute macro `endpoint` (in Nightly builds, run with -Z macro-backtrace for more info) -error[E0277]: the trait bound `fn(RequestContext<()>, std::string::String, dropshot::Query) -> impl Future, HttpError>> { for ApiEndpoint< as RequestContextArgument>::Context>>::from::non_extractor_as_last_argument}: dropshot::handler::HttpHandlerFunc<_, _, _>` is not satisfied +error[E0277]: the trait bound `fn(RequestContext<()>, std::string::String, dropshot::Query) -> impl Future, HttpError>> { for ApiEndpoint< as RequestContextArgument>::Context>>::from::non_extractor_as_last_argument}: dropshot::handler::HttpHandlerFunc<_, _, _>` is not satisfied --> tests/fail/bad_endpoint19.rs:24:10 | 20 | / #[endpoint { @@ -29,7 +29,7 @@ error[E0277]: the trait bound `fn(RequestContext<()>, std::string::String, drops 23 | | }] | |__- required by a bound introduced by this call 24 | async fn non_extractor_as_last_argument( - | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ the trait `dropshot::handler::HttpHandlerFunc<_, _, _>` is not implemented for fn item `fn(RequestContext<()>, std::string::String, dropshot::Query) -> impl Future, HttpError>> { for ApiEndpoint< as RequestContextArgument>::Context>>::from::non_extractor_as_last_argument}` + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ the trait `dropshot::handler::HttpHandlerFunc<_, _, _>` is not implemented for fn item `fn(RequestContext<()>, std::string::String, dropshot::Query) -> impl Future, HttpError>> { for ApiEndpoint< as RequestContextArgument>::Context>>::from::non_extractor_as_last_argument}` | note: required by a bound in `ApiEndpoint::::new` --> src/api_description.rs diff --git a/dropshot/tests/fail/bad_endpoint3.stderr b/dropshot/tests/fail/bad_endpoint3.stderr index 541edbd5..610cc9c2 100644 --- a/dropshot/tests/fail/bad_endpoint3.stderr +++ b/dropshot/tests/fail/bad_endpoint3.stderr @@ -21,7 +21,7 @@ note: required by a bound in `need_exclusive_extractor` | ------ required by a bound in this function = note: this error originates in the attribute macro `endpoint` (in Nightly builds, run with -Z macro-backtrace for more info) -error[E0277]: the trait bound `fn(RequestContext<()>, String) -> impl Future, HttpError>> { for ApiEndpoint< as RequestContextArgument>::Context>>::from::bad_endpoint}: dropshot::handler::HttpHandlerFunc<_, _, _>` is not satisfied +error[E0277]: the trait bound `fn(RequestContext<()>, String) -> impl Future, HttpError>> { for ApiEndpoint< as RequestContextArgument>::Context>>::from::bad_endpoint}: dropshot::handler::HttpHandlerFunc<_, _, _>` is not satisfied --> tests/fail/bad_endpoint3.rs:16:10 | 12 | / #[endpoint { @@ -30,7 +30,7 @@ error[E0277]: the trait bound `fn(RequestContext<()>, String) -> impl Future` is not implemented for fn item `fn(RequestContext<()>, String) -> impl Future, HttpError>> { for ApiEndpoint< as RequestContextArgument>::Context>>::from::bad_endpoint}` + | ^^^^^^^^^^^^ the trait `dropshot::handler::HttpHandlerFunc<_, _, _>` is not implemented for fn item `fn(RequestContext<()>, String) -> impl Future, HttpError>> { for ApiEndpoint< as RequestContextArgument>::Context>>::from::bad_endpoint}` | note: required by a bound in `ApiEndpoint::::new` --> src/api_description.rs diff --git a/dropshot/tests/fail/bad_endpoint4.stderr b/dropshot/tests/fail/bad_endpoint4.stderr index fe4eccc8..549f7aa9 100644 --- a/dropshot/tests/fail/bad_endpoint4.stderr +++ b/dropshot/tests/fail/bad_endpoint4.stderr @@ -98,7 +98,7 @@ note: required by a bound in `dropshot::Query` | pub struct Query { | ^^^^^^^^^^^^^^^^ required by this bound in `Query` -error[E0277]: the trait bound `fn(RequestContext<()>, dropshot::Query) -> impl Future, HttpError>> { for ApiEndpoint< as RequestContextArgument>::Context>>::from::bad_endpoint}: dropshot::handler::HttpHandlerFunc<_, _, _>` is not satisfied +error[E0277]: the trait bound `fn(RequestContext<()>, dropshot::Query) -> impl Future, HttpError>> { for ApiEndpoint< as RequestContextArgument>::Context>>::from::bad_endpoint}: dropshot::handler::HttpHandlerFunc<_, _, _>` is not satisfied --> tests/fail/bad_endpoint4.rs:21:10 | 17 | / #[endpoint { @@ -107,7 +107,7 @@ error[E0277]: the trait bound `fn(RequestContext<()>, dropshot::Query` is not implemented for fn item `fn(RequestContext<()>, dropshot::Query) -> impl Future, HttpError>> { for ApiEndpoint< as RequestContextArgument>::Context>>::from::bad_endpoint}` + | ^^^^^^^^^^^^ the trait `dropshot::handler::HttpHandlerFunc<_, _, _>` is not implemented for fn item `fn(RequestContext<()>, dropshot::Query) -> impl Future, HttpError>> { for ApiEndpoint< as RequestContextArgument>::Context>>::from::bad_endpoint}` | note: required by a bound in `ApiEndpoint::::new` --> src/api_description.rs diff --git a/dropshot/tests/fail/bad_endpoint5.stderr b/dropshot/tests/fail/bad_endpoint5.stderr index de826f37..d907b829 100644 --- a/dropshot/tests/fail/bad_endpoint5.stderr +++ b/dropshot/tests/fail/bad_endpoint5.stderr @@ -51,7 +51,7 @@ note: required by a bound in `dropshot::Query` | pub struct Query { | ^^^^^^^^^^^^^^^^ required by this bound in `Query` -error[E0277]: the trait bound `fn(RequestContext<()>, dropshot::Query) -> impl Future, HttpError>> { for ApiEndpoint< as RequestContextArgument>::Context>>::from::bad_endpoint}: dropshot::handler::HttpHandlerFunc<_, _, _>` is not satisfied +error[E0277]: the trait bound `fn(RequestContext<()>, dropshot::Query) -> impl Future, HttpError>> { for ApiEndpoint< as RequestContextArgument>::Context>>::from::bad_endpoint}: dropshot::handler::HttpHandlerFunc<_, _, _>` is not satisfied --> tests/fail/bad_endpoint5.rs:23:10 | 19 | / #[endpoint { @@ -60,7 +60,7 @@ error[E0277]: the trait bound `fn(RequestContext<()>, dropshot::Query` is not implemented for fn item `fn(RequestContext<()>, dropshot::Query) -> impl Future, HttpError>> { for ApiEndpoint< as RequestContextArgument>::Context>>::from::bad_endpoint}` + | ^^^^^^^^^^^^ the trait `dropshot::handler::HttpHandlerFunc<_, _, _>` is not implemented for fn item `fn(RequestContext<()>, dropshot::Query) -> impl Future, HttpError>> { for ApiEndpoint< as RequestContextArgument>::Context>>::from::bad_endpoint}` | note: required by a bound in `ApiEndpoint::::new` --> src/api_description.rs diff --git a/dropshot/tests/fail/bad_endpoint7.stderr b/dropshot/tests/fail/bad_endpoint7.stderr index eb251422..0a666dfd 100644 --- a/dropshot/tests/fail/bad_endpoint7.stderr +++ b/dropshot/tests/fail/bad_endpoint7.stderr @@ -157,7 +157,7 @@ note: required by a bound in `HttpResponseOk` | pub struct HttpResponseOk( | ^^^^^^^^^^^^^^^^^^^ required by this bound in `HttpResponseOk` -error[E0277]: the trait bound `fn(RequestContext<()>) -> impl Future, HttpError>> { for ApiEndpoint< as RequestContextArgument>::Context>>::from::bad_endpoint}: dropshot::handler::HttpHandlerFunc<_, _, _>` is not satisfied +error[E0277]: the trait bound `fn(RequestContext<()>) -> impl Future, HttpError>> { for ApiEndpoint< as RequestContextArgument>::Context>>::from::bad_endpoint}: dropshot::handler::HttpHandlerFunc<_, _, _>` is not satisfied --> tests/fail/bad_endpoint7.rs:22:10 | 18 | / #[endpoint { @@ -166,7 +166,7 @@ error[E0277]: the trait bound `fn(RequestContext<()>) -> impl Future` is not implemented for fn item `fn(RequestContext<()>) -> impl Future, HttpError>> { for ApiEndpoint< as RequestContextArgument>::Context>>::from::bad_endpoint}` + | ^^^^^^^^^^^^ the trait `dropshot::handler::HttpHandlerFunc<_, _, _>` is not implemented for fn item `fn(RequestContext<()>) -> impl Future, HttpError>> { for ApiEndpoint< as RequestContextArgument>::Context>>::from::bad_endpoint}` | note: required by a bound in `ApiEndpoint::::new` --> src/api_description.rs diff --git a/dropshot/tests/fail/bad_endpoint9.stderr b/dropshot/tests/fail/bad_endpoint9.stderr index 9317957d..072b2558 100644 --- a/dropshot/tests/fail/bad_endpoint9.stderr +++ b/dropshot/tests/fail/bad_endpoint9.stderr @@ -18,7 +18,7 @@ error[E0277]: the trait bound `dropshot::Query: RequestContextArgum = help: the trait `RequestContextArgument` is implemented for `RequestContext` = note: this error originates in the attribute macro `endpoint` (in Nightly builds, run with -Z macro-backtrace for more info) -error[E0277]: the trait bound `fn(dropshot::Query) -> impl Future, HttpError>> { for ApiEndpoint< as RequestContextArgument>::Context>>::from::bad_endpoint}: dropshot::handler::HttpHandlerFunc<_, _, _>` is not satisfied +error[E0277]: the trait bound `fn(dropshot::Query) -> impl Future, HttpError>> { for ApiEndpoint< as RequestContextArgument>::Context>>::from::bad_endpoint}: dropshot::handler::HttpHandlerFunc<_, _, _>` is not satisfied --> tests/fail/bad_endpoint9.rs:24:10 | 20 | / #[endpoint { @@ -27,7 +27,7 @@ error[E0277]: the trait bound `fn(dropshot::Query) -> impl Future` is not implemented for fn item `fn(dropshot::Query) -> impl Future, HttpError>> { for ApiEndpoint< as RequestContextArgument>::Context>>::from::bad_endpoint}` + | ^^^^^^^^^^^^ the trait `dropshot::handler::HttpHandlerFunc<_, _, _>` is not implemented for fn item `fn(dropshot::Query) -> impl Future, HttpError>> { for ApiEndpoint< as RequestContextArgument>::Context>>::from::bad_endpoint}` | note: required by a bound in `ApiEndpoint::::new` --> src/api_description.rs diff --git a/dropshot/tests/fail/bad_version_backwards.rs b/dropshot/tests/fail/bad_version_backwards.rs new file mode 100644 index 00000000..0384180e --- /dev/null +++ b/dropshot/tests/fail/bad_version_backwards.rs @@ -0,0 +1,23 @@ +// Copyright 2024 Oxide Computer Company + +//! Tests the case where the "versions" field is not parseable +//! +//! We do not bother testing most of the other "bad version" cases because we +//! have exhaustive unit tests for those. + +#![allow(unused_imports)] + +use dropshot::endpoint; +use dropshot::HttpError; +use dropshot::HttpResponseOk; + +#[endpoint { + method = GET, + path = "/test", + versions = "1.2.3".."1.2.2" +}] +async fn bad_version_backwards() -> Result, HttpError> { + Ok(HttpResponseOk(())) +} + +fn main() {} diff --git a/dropshot/tests/fail/bad_version_backwards.stderr b/dropshot/tests/fail/bad_version_backwards.stderr new file mode 100644 index 00000000..f0043538 --- /dev/null +++ b/dropshot/tests/fail/bad_version_backwards.stderr @@ -0,0 +1,5 @@ +error: "from" version (1.2.3) must be earlier than "until" version (1.2.2) + --> tests/fail/bad_version_backwards.rs:17:16 + | +17 | versions = "1.2.3".."1.2.2" + | ^^^^^^^ diff --git a/dropshot/tests/test_config.rs b/dropshot/tests/test_config.rs index f2b71d7b..69481874 100644 --- a/dropshot/tests/test_config.rs +++ b/dropshot/tests/test_config.rs @@ -620,3 +620,31 @@ async fn test_config_handler_task_mode_detached() { logctx.cleanup_successful(); } + +#[tokio::test] +async fn test_unversioned_servers_with_versioned_routes() { + #[dropshot::endpoint { + method = GET, + path = "/handler", + versions = "1.0.1".."1.0.1", + }] + async fn versioned_handler( + _rqctx: RequestContext, + ) -> Result, HttpError> { + Ok(HttpResponseOk(3)) + } + + let logctx = + create_log_context("test_unversioned_servers_with_versioned_routes"); + let mut api = dropshot::ApiDescription::new(); + api.register(versioned_handler).unwrap(); + let Err(error) = ServerBuilder::new(api, 0, logctx.log.clone()).start() + else { + panic!("expected failure to create server"); + }; + println!("{}", error); + assert_eq!( + error.to_string(), + "unversioned servers cannot have endpoints with specific versions" + ); +} diff --git a/dropshot/tests/test_openapi.json b/dropshot/tests/test_openapi.json index 79ff0c49..108565b0 100644 --- a/dropshot/tests/test_openapi.json +++ b/dropshot/tests/test_openapi.json @@ -2,7 +2,7 @@ "openapi": "3.0.3", "info": { "title": "test", - "version": "threeve" + "version": "3.5.0" }, "paths": { "/datagoeshere": { diff --git a/dropshot/tests/test_openapi.rs b/dropshot/tests/test_openapi.rs index 7fbec5ac..9f2c3cc9 100644 --- a/dropshot/tests/test_openapi.rs +++ b/dropshot/tests/test_openapi.rs @@ -544,7 +544,8 @@ fn test_openapi() -> anyhow::Result<()> { let api = make_api(None)?; let mut output = Cursor::new(Vec::new()); - let _ = api.openapi("test", "threeve").write(&mut output); + let _ = + api.openapi("test", semver::Version::new(3, 5, 0)).write(&mut output); let actual = from_utf8(output.get_ref()).unwrap(); expectorate::assert_contents("tests/test_openapi.json", actual); @@ -570,7 +571,7 @@ fn test_openapi_fuller() -> anyhow::Result<()> { let mut output = Cursor::new(Vec::new()); let _ = api - .openapi("test", "1985.7") + .openapi("test", semver::Version::new(1985, 7, 0)) .description("gusty winds may exist") .contact_name("old mate") .license_name("CDDL") diff --git a/dropshot/tests/test_openapi_fuller.json b/dropshot/tests/test_openapi_fuller.json index 3a4f3124..0a5fc99c 100644 --- a/dropshot/tests/test_openapi_fuller.json +++ b/dropshot/tests/test_openapi_fuller.json @@ -10,7 +10,7 @@ "license": { "name": "CDDL" }, - "version": "1985.7" + "version": "1985.7.0" }, "paths": { "/datagoeshere": { diff --git a/dropshot/tests/test_openapi_overrides_v1.json b/dropshot/tests/test_openapi_overrides_v1.json new file mode 100644 index 00000000..6046c992 --- /dev/null +++ b/dropshot/tests/test_openapi_overrides_v1.json @@ -0,0 +1,78 @@ +{ + "openapi": "3.0.3", + "info": { + "title": "An API", + "version": "1.0.0" + }, + "paths": { + "/demo": { + "get": { + "operationId": "the_operation", + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MyReturn" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + } + }, + "components": { + "schemas": { + "Error": { + "description": "Error information from a response.", + "type": "object", + "properties": { + "error_code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "request_id": { + "type": "string" + } + }, + "required": [ + "message", + "request_id" + ] + }, + "MyReturn": { + "type": "object", + "properties": { + "q": { + "type": "string" + } + }, + "required": [ + "q" + ] + } + }, + "responses": { + "Error": { + "description": "Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } +} diff --git a/dropshot/tests/test_openapi_overrides_v2.json b/dropshot/tests/test_openapi_overrides_v2.json new file mode 100644 index 00000000..f483c805 --- /dev/null +++ b/dropshot/tests/test_openapi_overrides_v2.json @@ -0,0 +1,78 @@ +{ + "openapi": "3.0.3", + "info": { + "title": "An API", + "version": "2.0.0" + }, + "paths": { + "/demo": { + "get": { + "operationId": "the_operation", + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MyReturn" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + } + }, + "components": { + "schemas": { + "Error": { + "description": "Error information from a response.", + "type": "object", + "properties": { + "error_code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "request_id": { + "type": "string" + } + }, + "required": [ + "message", + "request_id" + ] + }, + "MyReturn": { + "type": "object", + "properties": { + "r": { + "type": "string" + } + }, + "required": [ + "r" + ] + } + }, + "responses": { + "Error": { + "description": "Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } +} diff --git a/dropshot/tests/test_pagination_schema.json b/dropshot/tests/test_pagination_schema.json index b70d416e..e28d0135 100644 --- a/dropshot/tests/test_pagination_schema.json +++ b/dropshot/tests/test_pagination_schema.json @@ -10,7 +10,7 @@ "license": { "name": "CDDL" }, - "version": "1985.7" + "version": "1985.7.0" }, "paths": { "/super_pages": { diff --git a/dropshot/tests/test_pagination_schema.rs b/dropshot/tests/test_pagination_schema.rs index 8f1f85fb..70f5386c 100644 --- a/dropshot/tests/test_pagination_schema.rs +++ b/dropshot/tests/test_pagination_schema.rs @@ -48,7 +48,7 @@ fn test_pagination_schema() -> anyhow::Result<()> { let mut output = Cursor::new(Vec::new()); let _ = api - .openapi("test", "1985.7") + .openapi("test", semver::Version::new(1985, 7, 0)) .description("gusty winds may exist") .contact_name("old mate") .license_name("CDDL") diff --git a/dropshot/tests/test_versions.rs b/dropshot/tests/test_versions.rs new file mode 100644 index 00000000..6b03af4d --- /dev/null +++ b/dropshot/tests/test_versions.rs @@ -0,0 +1,521 @@ +// Copyright 2024 Oxide Computer Company + +//! Exercise the API versioning behavior + +use dropshot::endpoint; +use dropshot::ApiDescription; +use dropshot::ClientSpecifiesVersionInHeader; +use dropshot::HttpError; +use dropshot::HttpErrorResponseBody; +use dropshot::HttpResponseOk; +use dropshot::RequestContext; +use dropshot::ServerBuilder; +use dropshot::VersionPolicy; +use reqwest::Method; +use reqwest::StatusCode; +use schemars::JsonSchema; +use semver::Version; +use serde::Deserialize; +use serde::Serialize; +use std::io::Cursor; + +pub mod common; + +const VERSION_HEADER_NAME: &str = "dropshot-test-version"; +const HANDLER1_MSG: &str = "handler1"; +const HANDLER2_MSG: &str = "handler2"; +const HANDLER3_MSG: &str = "handler3"; +const HANDLER4_MSG: &str = "handler3"; + +fn api() -> ApiDescription<()> { + let mut api = ApiDescription::new(); + api.register(handler1).unwrap(); + api.register(handler2).unwrap(); + api.register(handler3).unwrap(); + api.register(handler4).unwrap(); + api +} + +fn api_to_openapi_string( + api: &ApiDescription, + name: &str, + version: &semver::Version, +) -> String { + let mut contents = Cursor::new(Vec::new()); + api.openapi(name, version.clone()).write(&mut contents).unwrap(); + String::from_utf8(contents.get_ref().to_vec()).unwrap() +} + +// This is just here so that we can tell that types are included in the spec iff +// they are referenced by endpoints in that version of the spec. +#[derive(Deserialize, JsonSchema, Serialize)] +struct EarlyReturn { + real_message: String, +} + +#[endpoint { + method = GET, + path = "/demo", + versions = .."1.0.0", +}] +async fn handler1( + _rqctx: RequestContext<()>, +) -> Result, HttpError> { + Ok(HttpResponseOk(EarlyReturn { real_message: HANDLER1_MSG.to_string() })) +} + +#[endpoint { + method = GET, + path = "/demo", + versions = "1.1.0".."1.2.0", +}] +async fn handler2( + _rqctx: RequestContext<()>, +) -> Result, HttpError> { + Ok(HttpResponseOk(HANDLER2_MSG)) +} + +#[endpoint { + method = GET, + path = "/demo", + versions = "1.3.0".., +}] +async fn handler3( + _rqctx: RequestContext<()>, +) -> Result, HttpError> { + Ok(HttpResponseOk(HANDLER3_MSG)) +} + +#[endpoint { + method = PUT, + path = "/demo", + versions = .. +}] +async fn handler4( + _rqctx: RequestContext<()>, +) -> Result, HttpError> { + Ok(HttpResponseOk(HANDLER4_MSG)) +} + +/// Define an API with different versions and run through an exhaustive battery +/// of tests showing that we use the correct handler for each incoming request +/// based on the version requested. +#[tokio::test] +async fn test_versions() { + let logctx = common::create_log_context("test_versions"); + let server = ServerBuilder::new(api(), (), logctx.log.clone()) + .version_policy(VersionPolicy::Dynamic(Box::new( + ClientSpecifiesVersionInHeader::new( + VERSION_HEADER_NAME.parse().unwrap(), + Version::new(1, 4, 0), + ), + ))) + .start() + .unwrap(); + + let server_addr = server.local_addr(); + let mkurl = |path: &str| format!("http://{}{}", server_addr, path); + let client = reqwest::Client::new(); + + #[derive(Debug)] + struct TestCase<'a> { + /// HTTP method for the request + method: Method, + /// HTTP path for the request + path: &'a str, + /// Value to pass for our requested version + /// (string supports providing bad (non-semver) input) + header: Option<&'a str>, + /// expected HTTP response status code + expected_status: StatusCode, + /// expected HTTP response body contents + body_contents: String, + } + + impl<'a> TestCase<'a> { + fn new( + method: Method, + path: &'a str, + header: Option<&'a str>, + expected_status: StatusCode, + body_contents: S, + ) -> TestCase<'a> + where + String: From, + { + TestCase { + method, + path, + header, + expected_status, + body_contents: String::from(body_contents), + } + } + } + + let test_cases = [ + // Test that errors produced by the version policy get propagated + // through to the client. + TestCase::new( + Method::GET, + "/demo", + None, + StatusCode::BAD_REQUEST, + format!("missing expected header {:?}", VERSION_HEADER_NAME), + ), + TestCase::new( + Method::GET, + "/demo", + Some("not-a-semver"), + StatusCode::BAD_REQUEST, + format!( + "bad value for header {:?}: unexpected character 'n' while \ + parsing major version number: not-a-semver", + VERSION_HEADER_NAME + ), + ), + // Versions prior to (not including) 1.0.0 get "handler1". + TestCase::new( + Method::GET, + "/demo", + Some("0.9.0"), + StatusCode::OK, + HANDLER1_MSG, + ), + // Versions between 1.0.0 (inclusive) and 1.1.0 (exclusive) do not + // exist. + // + // Because there's a PUT handler for all versions, the expected error is + // 405 ("Method Not Allowed"), not 404 ("Not Found"). + TestCase::new( + Method::GET, + "/demo", + Some("1.0.0"), + StatusCode::METHOD_NOT_ALLOWED, + StatusCode::METHOD_NOT_ALLOWED.canonical_reason().unwrap(), + ), + TestCase::new( + Method::GET, + "/demo", + Some("1.0.99"), + StatusCode::METHOD_NOT_ALLOWED, + StatusCode::METHOD_NOT_ALLOWED.canonical_reason().unwrap(), + ), + // Versions between 1.1.0 (inclusive) and 1.2.0 (exclusive) get + // "handler2". + TestCase::new( + Method::GET, + "/demo", + Some("1.1.0"), + StatusCode::OK, + HANDLER2_MSG, + ), + TestCase::new( + Method::GET, + "/demo", + Some("1.1.99"), + StatusCode::OK, + HANDLER2_MSG, + ), + // Versions between 1.2.0 (inclusive) and 1.3.0 (exclusive) do not + // exist. See above for why this is a 405. + TestCase::new( + Method::GET, + "/demo", + Some("1.2.0"), + StatusCode::METHOD_NOT_ALLOWED, + StatusCode::METHOD_NOT_ALLOWED.canonical_reason().unwrap(), + ), + TestCase::new( + Method::GET, + "/demo", + Some("1.2.99"), + StatusCode::METHOD_NOT_ALLOWED, + StatusCode::METHOD_NOT_ALLOWED.canonical_reason().unwrap(), + ), + // Versions after 1.3.0 (inclusive) get "handler3". + TestCase::new( + Method::GET, + "/demo", + Some("1.3.0"), + StatusCode::OK, + HANDLER3_MSG, + ), + // For all of these versions, a PUT should get handler4. + TestCase::new( + Method::PUT, + "/demo", + Some("0.9.0"), + StatusCode::OK, + HANDLER4_MSG, + ), + TestCase::new( + Method::PUT, + "/demo", + Some("1.0.0"), + StatusCode::OK, + HANDLER4_MSG, + ), + TestCase::new( + Method::PUT, + "/demo", + Some("1.1.0"), + StatusCode::OK, + HANDLER4_MSG, + ), + TestCase::new( + Method::PUT, + "/demo", + Some("1.3.1"), + StatusCode::OK, + HANDLER4_MSG, + ), + TestCase::new( + Method::PUT, + "/demo", + Some("1.4.0"), + StatusCode::OK, + HANDLER4_MSG, + ), + TestCase::new( + Method::PUT, + "/demo", + Some("1.5.0"), + StatusCode::BAD_REQUEST, + "server does not support this API version: 1.5.0", + ), + ]; + + for t in test_cases { + println!("test case: {:?}", t); + let mut request = client.request(t.method, mkurl(t.path)); + if let Some(h) = t.header { + request = request.header(VERSION_HEADER_NAME, h); + } + let response = request.send().await.unwrap(); + assert_eq!(response.status(), t.expected_status); + + if !t.expected_status.is_success() { + let error: HttpErrorResponseBody = response.json().await.unwrap(); + assert_eq!(error.message, t.body_contents); + } else { + // This is ugly. But it's concise! + // + // We want to use a different type for `handler1` just so that we + // can check in test_versions_openapi() that it appears in the + // OpenAPI spec only in the appropriate versions. So if we're + // expecting to get something back from handler1, then parse the + // type a little differently. + let body: String = if t.body_contents == HANDLER1_MSG { + let body: EarlyReturn = response.json().await.unwrap(); + body.real_message + } else { + response.json().await.unwrap() + }; + + assert_eq!(body, t.body_contents); + } + } +} + +/// Test that the generated OpenAPI spec only refers to handlers in that version +/// and types that are used by those handlers. +#[test] +fn test_versions_openapi() { + let api = api(); + + for version in ["0.9.0", "1.0.0", "1.1.0", "1.3.1", "1.4.0"] { + let semver: semver::Version = version.parse().unwrap(); + let mut found = Cursor::new(Vec::new()); + api.openapi("Evolving API", semver).write(&mut found).unwrap(); + let actual = std::str::from_utf8(found.get_ref()).unwrap(); + expectorate::assert_contents( + &format!("tests/test_versions_v{}.json", version), + actual, + ); + } +} + +/// Test three different ways to define the same operation in two versions +/// (using different handlers). These should all produce the same pair of +/// specs. +#[test] +fn test_versions_openapi_same_names() { + // This approach uses freestanding functions in separate modules. + let api_function_modules = { + mod v1 { + use super::*; + + #[derive(JsonSchema, Serialize)] + pub struct MyReturn { + #[allow(dead_code)] + q: String, + } + + #[endpoint { + method = GET, + path = "/demo", + versions = .."2.0.0" + }] + pub async fn the_operation( + _rqctx: RequestContext<()>, + ) -> Result, HttpError> { + unimplemented!(); + } + } + + mod v2 { + use super::*; + + #[derive(JsonSchema, Serialize)] + pub struct MyReturn { + #[allow(dead_code)] + r: String, + } + + #[endpoint { + method = GET, + path = "/demo", + versions = "2.0.0".. + }] + pub async fn the_operation( + _rqctx: RequestContext<()>, + ) -> Result, HttpError> { + unimplemented!(); + } + } + + let mut api = ApiDescription::new(); + api.register(v1::the_operation).unwrap(); + api.register(v2::the_operation).unwrap(); + api + }; + + // This approach uses freestanding functions and types all in one module. + // This requires applying overrides to the names in order to have them show + // up with the same name in each version. + let api_function_overrides = { + #[derive(JsonSchema, Serialize)] + #[schemars(rename = "MyReturn")] + struct MyReturnV1 { + #[allow(dead_code)] + q: String, + } + + #[endpoint { + method = GET, + path = "/demo", + versions = .."2.0.0", + operation_id = "the_operation", + }] + async fn the_operation_v1( + _rqctx: RequestContext<()>, + ) -> Result, HttpError> { + unimplemented!(); + } + + #[derive(JsonSchema, Serialize)] + #[schemars(rename = "MyReturn")] + struct MyReturnV2 { + #[allow(dead_code)] + r: String, + } + + #[endpoint { + method = GET, + path = "/demo", + versions = "2.0.0".., + operation_id = "the_operation" + }] + async fn the_operation_v2( + _rqctx: RequestContext<()>, + ) -> Result, HttpError> { + unimplemented!(); + } + + let mut api = ApiDescription::new(); + api.register(the_operation_v1).unwrap(); + api.register(the_operation_v2).unwrap(); + api + }; + + // This approach uses the trait-based interface, which requires using + // `operation_id` to override the operation name if you want the name to be + // the same across versions. + let api_trait_overrides = + trait_based::my_api_mod::stub_api_description().unwrap(); + + const NAME: &str = "An API"; + let v1 = semver::Version::new(1, 0, 0); + let v2 = semver::Version::new(2, 0, 0); + let func_mods_v1 = api_to_openapi_string(&api_function_modules, NAME, &v1); + let func_mods_v2 = api_to_openapi_string(&api_function_modules, NAME, &v2); + let func_overrides_v1 = + api_to_openapi_string(&api_function_overrides, NAME, &v1); + let func_overrides_v2 = + api_to_openapi_string(&api_function_overrides, NAME, &v2); + let traits_v1 = api_to_openapi_string(&api_trait_overrides, NAME, &v1); + let traits_v2 = api_to_openapi_string(&api_trait_overrides, NAME, &v2); + + expectorate::assert_contents( + "tests/test_openapi_overrides_v1.json", + &func_overrides_v1, + ); + expectorate::assert_contents( + "tests/test_openapi_overrides_v2.json", + &func_overrides_v2, + ); + + assert_eq!(func_mods_v1, func_overrides_v1); + assert_eq!(func_mods_v1, traits_v1); + assert_eq!(func_mods_v2, func_overrides_v2); + assert_eq!(func_mods_v2, traits_v2); +} + +// The contents of this module logically belongs inside +// test_versions_openapi_same_names(). It can't go there due to +// oxidecomputer/dropshot#1128. +mod trait_based { + use super::*; + + #[derive(JsonSchema, Serialize)] + #[schemars(rename = "MyReturn")] + pub struct MyReturnV1 { + #[allow(dead_code)] + q: String, + } + + #[derive(JsonSchema, Serialize)] + #[schemars(rename = "MyReturn")] + pub struct MyReturnV2 { + #[allow(dead_code)] + r: String, + } + + #[dropshot::api_description] + // This `allow(dead_code)` works around oxidecomputer/dropshot#1129. + #[allow(dead_code)] + pub trait MyApi { + type Context; + + #[endpoint { + method = GET, + path = "/demo", + versions = .."2.0.0", + operation_id = "the_operation", + }] + async fn the_operation_v1( + _rqctx: RequestContext, + ) -> Result, HttpError>; + + #[endpoint { + method = GET, + path = "/demo", + versions = "2.0.0".., + operation_id = "the_operation" + }] + async fn the_operation_v2( + _rqctx: RequestContext, + ) -> Result, HttpError>; + } +} diff --git a/dropshot/tests/test_versions_v0.9.0.json b/dropshot/tests/test_versions_v0.9.0.json new file mode 100644 index 00000000..93dc53e2 --- /dev/null +++ b/dropshot/tests/test_versions_v0.9.0.json @@ -0,0 +1,100 @@ +{ + "openapi": "3.0.3", + "info": { + "title": "Evolving API", + "version": "0.9.0" + }, + "paths": { + "/demo": { + "get": { + "operationId": "handler1", + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EarlyReturn" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + }, + "put": { + "operationId": "handler4", + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "title": "String", + "type": "string" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + } + }, + "components": { + "schemas": { + "EarlyReturn": { + "type": "object", + "properties": { + "real_message": { + "type": "string" + } + }, + "required": [ + "real_message" + ] + }, + "Error": { + "description": "Error information from a response.", + "type": "object", + "properties": { + "error_code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "request_id": { + "type": "string" + } + }, + "required": [ + "message", + "request_id" + ] + } + }, + "responses": { + "Error": { + "description": "Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } +} diff --git a/dropshot/tests/test_versions_v1.0.0.json b/dropshot/tests/test_versions_v1.0.0.json new file mode 100644 index 00000000..524fd7f6 --- /dev/null +++ b/dropshot/tests/test_versions_v1.0.0.json @@ -0,0 +1,68 @@ +{ + "openapi": "3.0.3", + "info": { + "title": "Evolving API", + "version": "1.0.0" + }, + "paths": { + "/demo": { + "put": { + "operationId": "handler4", + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "title": "String", + "type": "string" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + } + }, + "components": { + "schemas": { + "Error": { + "description": "Error information from a response.", + "type": "object", + "properties": { + "error_code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "request_id": { + "type": "string" + } + }, + "required": [ + "message", + "request_id" + ] + } + }, + "responses": { + "Error": { + "description": "Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } +} diff --git a/dropshot/tests/test_versions_v1.1.0.json b/dropshot/tests/test_versions_v1.1.0.json new file mode 100644 index 00000000..720ed5a5 --- /dev/null +++ b/dropshot/tests/test_versions_v1.1.0.json @@ -0,0 +1,90 @@ +{ + "openapi": "3.0.3", + "info": { + "title": "Evolving API", + "version": "1.1.0" + }, + "paths": { + "/demo": { + "get": { + "operationId": "handler2", + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "title": "String", + "type": "string" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + }, + "put": { + "operationId": "handler4", + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "title": "String", + "type": "string" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + } + }, + "components": { + "schemas": { + "Error": { + "description": "Error information from a response.", + "type": "object", + "properties": { + "error_code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "request_id": { + "type": "string" + } + }, + "required": [ + "message", + "request_id" + ] + } + }, + "responses": { + "Error": { + "description": "Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } +} diff --git a/dropshot/tests/test_versions_v1.3.1.json b/dropshot/tests/test_versions_v1.3.1.json new file mode 100644 index 00000000..31111ea4 --- /dev/null +++ b/dropshot/tests/test_versions_v1.3.1.json @@ -0,0 +1,90 @@ +{ + "openapi": "3.0.3", + "info": { + "title": "Evolving API", + "version": "1.3.1" + }, + "paths": { + "/demo": { + "get": { + "operationId": "handler3", + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "title": "String", + "type": "string" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + }, + "put": { + "operationId": "handler4", + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "title": "String", + "type": "string" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + } + }, + "components": { + "schemas": { + "Error": { + "description": "Error information from a response.", + "type": "object", + "properties": { + "error_code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "request_id": { + "type": "string" + } + }, + "required": [ + "message", + "request_id" + ] + } + }, + "responses": { + "Error": { + "description": "Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } +} diff --git a/dropshot/tests/test_versions_v1.4.0.json b/dropshot/tests/test_versions_v1.4.0.json new file mode 100644 index 00000000..f421be3e --- /dev/null +++ b/dropshot/tests/test_versions_v1.4.0.json @@ -0,0 +1,90 @@ +{ + "openapi": "3.0.3", + "info": { + "title": "Evolving API", + "version": "1.4.0" + }, + "paths": { + "/demo": { + "get": { + "operationId": "handler3", + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "title": "String", + "type": "string" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + }, + "put": { + "operationId": "handler4", + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "title": "String", + "type": "string" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + } + }, + "components": { + "schemas": { + "Error": { + "description": "Error information from a response.", + "type": "object", + "properties": { + "error_code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "request_id": { + "type": "string" + } + }, + "required": [ + "message", + "request_id" + ] + } + }, + "responses": { + "Error": { + "description": "Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } +} diff --git a/dropshot_endpoint/Cargo.toml b/dropshot_endpoint/Cargo.toml index 3a372175..19dc7d8c 100644 --- a/dropshot_endpoint/Cargo.toml +++ b/dropshot_endpoint/Cargo.toml @@ -17,6 +17,7 @@ workspace = true heck = "0.5.0" proc-macro2 = "1" quote = "1" +semver = "1.0.23" serde_tokenstream = "0.2.2" [dependencies.serde] diff --git a/dropshot_endpoint/src/api_trait.rs b/dropshot_endpoint/src/api_trait.rs index 02ed6b17..30a2206b 100644 --- a/dropshot_endpoint/src/api_trait.rs +++ b/dropshot_endpoint/src/api_trait.rs @@ -1676,12 +1676,20 @@ mod tests { trait MyTrait { type Context; - #[endpoint { method = GET, path = "/xyz" }] + #[endpoint { + method = GET, + path = "/xyz", + versions = "1.2.3".. + }] async fn handler_xyz( rqctx: RequestContext, ) -> Result, HttpError>; - #[channel { protocol = WEBSOCKETS, path = "/ws" }] + #[channel { + protocol = WEBSOCKETS, + path = "/ws", + versions = .. + }] async fn handler_ws( rqctx: RequestContext, upgraded: WebsocketConnection, diff --git a/dropshot_endpoint/src/channel.rs b/dropshot_endpoint/src/channel.rs index d6440cf6..9c195379 100644 --- a/dropshot_endpoint/src/channel.rs +++ b/dropshot_endpoint/src/channel.rs @@ -638,4 +638,33 @@ mod tests { &prettyplease::unparse(&parse_quote! { #item }), ); } + + #[test] + fn test_channel_with_versions() { + let input = quote! { + async fn my_channel( + _rqctx: RequestContext<()>, + _conn: WebsocketConnection, + ) -> WebsocketChannelResult { + Ok(()) + } + }; + + let (item, errors) = do_channel( + quote! { + protocol = WEBSOCKETS, + path = "/my/ws/channel", + versions = .."1.2.3", + }, + input.clone(), + ); + + assert!(errors.is_empty()); + + let file = parse_quote! { #item }; + assert_contents( + "tests/output/channel_with_versions.rs", + &prettyplease::unparse(&file), + ); + } } diff --git a/dropshot_endpoint/src/endpoint.rs b/dropshot_endpoint/src/endpoint.rs index d2601085..b9ea7d06 100644 --- a/dropshot_endpoint/src/endpoint.rs +++ b/dropshot_endpoint/src/endpoint.rs @@ -802,6 +802,102 @@ mod tests { ); } + #[test] + fn test_endpoint_with_versions_all() { + let (item, errors) = do_endpoint( + quote! { + method = GET, + path = "/test", + versions = .. + }, + quote! { + async fn handler_xyz( + _: RequestContext<()>, + ) -> Result { + Ok(()) + } + }, + ); + + assert!(errors.is_empty()); + assert_contents( + "tests/output/endpoint_with_versions_all.rs", + &prettyplease::unparse(&parse_quote! { #item }), + ); + } + + #[test] + fn test_endpoint_with_versions_from() { + let (item, errors) = do_endpoint( + quote! { + method = GET, + path = "/test", + versions = "1.2.3".. + }, + quote! { + async fn handler_xyz( + _: RequestContext<()>, + ) -> Result { + Ok(()) + } + }, + ); + + assert!(errors.is_empty()); + assert_contents( + "tests/output/endpoint_with_versions_from.rs", + &prettyplease::unparse(&parse_quote! { #item }), + ); + } + + #[test] + fn test_endpoint_with_versions_until() { + let (item, errors) = do_endpoint( + quote! { + method = GET, + path = "/test", + versions = .."1.2.3" + }, + quote! { + async fn handler_xyz( + _: RequestContext<()>, + ) -> Result { + Ok(()) + } + }, + ); + + assert!(errors.is_empty()); + assert_contents( + "tests/output/endpoint_with_versions_until.rs", + &prettyplease::unparse(&parse_quote! { #item }), + ); + } + + #[test] + fn test_endpoint_with_versions_from_until() { + let (item, errors) = do_endpoint( + quote! { + method = GET, + path = "/test", + versions = "1.2.3".."4.5.6", + }, + quote! { + async fn handler_xyz( + _: RequestContext<()>, + ) -> Result { + Ok(()) + } + }, + ); + + assert!(errors.is_empty()); + assert_contents( + "tests/output/endpoint_with_versions_from_until.rs", + &prettyplease::unparse(&parse_quote! { #item }), + ); + } + #[test] fn test_endpoint_invalid_item() { let (_, errors) = do_endpoint( @@ -944,4 +1040,139 @@ mod tests { &prettyplease::unparse(&parse_quote! { #item }), ); } + + #[test] + fn test_endpoint_bad_versions() { + let (_, errors) = do_endpoint( + quote! { + method = GET, + path = "/a/b/c", + versions = 1.2.3, + }, + quote! { + pub async fn handler_xyz( + _rqctx: RequestContext<()>, + ) -> Result, HttpError> { + Ok(()) + } + }, + ); + assert!(!errors.is_empty()); + assert_eq!( + errors.get(0).map(ToString::to_string), + Some("expected string literal".to_string()), + ); + + let (_, errors) = do_endpoint( + quote! { + method = GET, + path = "/a/b/c", + versions = "one dot two dot three", + }, + quote! { + pub async fn handler_xyz( + _rqctx: RequestContext<()>, + ) -> Result, HttpError> { + Ok(()) + } + }, + ); + assert!(!errors.is_empty()); + assert_eq!( + errors.get(0).map(ToString::to_string), + Some( + "expected semver: unexpected character 'o' while \ + parsing major version number" + .to_string() + ), + ); + + let (_, errors) = do_endpoint( + quote! { + method = GET, + path = "/a/b/c", + versions = "1.2", + }, + quote! { + pub async fn handler_xyz( + _rqctx: RequestContext<()>, + ) -> Result, HttpError> { + Ok(()) + } + }, + ); + assert!(!errors.is_empty()); + assert_eq!( + errors.get(0).map(ToString::to_string), + Some( + "expected semver: unexpected end of input while parsing \ + minor version number" + .to_string() + ), + ); + + let (_, errors) = do_endpoint( + quote! { + method = GET, + path = "/a/b/c", + versions = "1.2.3-pre", + }, + quote! { + pub async fn handler_xyz( + _rqctx: RequestContext<()>, + ) -> Result, HttpError> { + Ok(()) + } + }, + ); + assert!(!errors.is_empty()); + assert_eq!( + errors.get(0).map(ToString::to_string), + Some("semver pre-release string is not supported here".to_string()), + ); + + let (_, errors) = do_endpoint( + quote! { + method = GET, + path = "/a/b/c", + versions = "1.2.3+latest", + }, + quote! { + pub async fn handler_xyz( + _rqctx: RequestContext<()>, + ) -> Result, HttpError> { + Ok(()) + } + }, + ); + assert!(!errors.is_empty()); + assert_eq!( + errors.get(0).map(ToString::to_string), + Some("semver build metadata is not supported here".to_string()), + ); + + let (_, errors) = do_endpoint( + quote! { + method = GET, + path = "/a/b/c", + versions = "1.2.5".."1.2.3", + }, + quote! { + pub async fn handler_xyz( + _rqctx: RequestContext<()>, + ) -> Result, HttpError> { + Ok(()) + } + }, + ); + assert!(!errors.is_empty()); + assert_eq!( + errors.get(0).map(ToString::to_string), + Some( + "\"from\" version (1.2.5) must be earlier \ + than \"until\" version (1.2.3)" + .to_string() + ), + ); + } } diff --git a/dropshot_endpoint/src/metadata.rs b/dropshot_endpoint/src/metadata.rs index ff8663c5..c9609202 100644 --- a/dropshot_endpoint/src/metadata.rs +++ b/dropshot_endpoint/src/metadata.rs @@ -2,7 +2,7 @@ //! Code to handle metadata associated with an endpoint. -use proc_macro2::TokenStream; +use proc_macro2::{TokenStream, TokenTree}; use quote::{format_ident, quote, quote_spanned, ToTokens}; use serde::Deserialize; use syn::{spanned::Spanned, Error}; @@ -12,6 +12,7 @@ use crate::{ error_store::ErrorSink, util::{get_crate, is_wildcard_path, MacroKind, ValidContentType}, }; +use serde_tokenstream::ParseWrapper; #[allow(non_snake_case)] #[derive(Deserialize, Debug)] @@ -53,6 +54,7 @@ pub(crate) struct EndpointMetadata { pub(crate) deprecated: bool, pub(crate) content_type: Option, pub(crate) _dropshot_crate: Option, + pub(crate) versions: Option>, } impl EndpointMetadata { @@ -84,6 +86,7 @@ impl EndpointMetadata { deprecated, content_type, _dropshot_crate, + versions, } = self; if kind == MacroKind::Trait && _dropshot_crate.is_some() { @@ -140,6 +143,9 @@ impl EndpointMetadata { unpublished, deprecated, content_type, + versions: versions + .map(|h| h.into_inner()) + .unwrap_or(VersionRange::All), }) } else { unreachable!("no validation errors, but content_type is None") @@ -156,6 +162,14 @@ pub(crate) struct ValidatedEndpointMetadata { unpublished: bool, deprecated: bool, content_type: ValidContentType, + versions: VersionRange, +} + +fn semver_parts(x: &semver::Version) -> (u64, u64, u64) { + // This was validated during validation. + assert_eq!(x.pre, semver::Prerelease::EMPTY); + assert_eq!(x.build, semver::BuildMetadata::EMPTY); + (x.major, x.minor, x.patch) } impl ValidatedEndpointMetadata { @@ -195,6 +209,36 @@ impl ValidatedEndpointMetadata { quote! { .deprecated(true) } }); + let versions = match &self.versions { + VersionRange::All => quote! { #dropshot::ApiEndpointVersions::All }, + VersionRange::From(x) => { + let (major, minor, patch) = semver_parts(&x); + quote! { + #dropshot::ApiEndpointVersions::From( + semver::Version::new(#major, #minor, #patch) + ) + } + } + VersionRange::Until(y) => { + let (major, minor, patch) = semver_parts(&y); + quote! { + #dropshot::ApiEndpointVersions::Until( + semver::Version::new(#major, #minor, #patch) + ) + } + } + VersionRange::FromUntil(x, y) => { + let (xmajor, xminor, xpatch) = semver_parts(&x); + let (ymajor, yminor, ypatch) = semver_parts(&y); + quote! { + #dropshot::ApiEndpointVersions::from_until( + semver::Version::new(#xmajor, #xminor, #xpatch), + semver::Version::new(#ymajor, #yminor, #ypatch), + ).unwrap() + } + } + }; + let fn_call = match kind { ApiEndpointKind::Regular(endpoint_fn) => { quote_spanned! {endpoint_fn.span()=> @@ -204,6 +248,7 @@ impl ValidatedEndpointMetadata { #dropshot::Method::#method_ident, #content_type, #path, + #versions, ) } } @@ -223,6 +268,7 @@ impl ValidatedEndpointMetadata { #dropshot::Method::#method_ident, #content_type, #path, + #versions, ) } } @@ -239,6 +285,88 @@ impl ValidatedEndpointMetadata { } } +#[derive(Debug)] +pub(crate) enum VersionRange { + All, + From(semver::Version), + Until(semver::Version), + FromUntil(semver::Version, semver::Version), +} + +fn parse_semver(v: &syn::LitStr) -> syn::Result { + v.value() + .parse::() + .map_err(|e| { + syn::Error::new_spanned(v, format!("expected semver: {}", e)) + }) + .and_then(|s| { + if s.pre == semver::Prerelease::EMPTY { + Ok(s) + } else { + Err(syn::Error::new_spanned( + v, + String::from( + "semver pre-release string is not supported here", + ), + )) + } + }) + .and_then(|s| { + if s.build == semver::BuildMetadata::EMPTY { + Ok(s) + } else { + Err(syn::Error::new_spanned( + v, + String::from("semver build metadata is not supported here"), + )) + } + }) +} + +impl syn::parse::Parse for VersionRange { + fn parse(input: syn::parse::ParseStream) -> syn::Result { + let lookahead = input.lookahead1(); + if lookahead.peek(syn::Token![..]) { + let _ = input.parse::()?; + if input.is_empty() { + Ok(VersionRange::All) + } else { + let latest = input.parse::()?; + let latest_semver = parse_semver(&latest)?; + Ok(VersionRange::Until(latest_semver)) + } + } else { + let earliest = input.parse::()?; + let earliest_semver = parse_semver(&earliest)?; + let _ = input.parse::()?; + let lookahead = input.lookahead1(); + if lookahead.peek(syn::LitStr) { + let latest = input.parse::()?; + let latest_semver = parse_semver(&latest)?; + if latest_semver < earliest_semver { + let span: TokenStream = [ + TokenTree::from(earliest.token()), + TokenTree::from(latest.token()), + ] + .into_iter() + .collect(); + return Err(syn::Error::new_spanned( + span, + format!( + "\"from\" version ({}) must be earlier than \ + \"until\" version ({})", + earliest_semver, latest_semver, + ), + )); + } + Ok(VersionRange::FromUntil(earliest_semver, latest_semver)) + } else { + Ok(VersionRange::From(earliest_semver)) + } + } + } +} + #[allow(non_snake_case)] #[derive(Deserialize, Debug)] pub(crate) enum ChannelProtocol { @@ -258,6 +386,7 @@ pub(crate) struct ChannelMetadata { #[serde(default)] pub(crate) deprecated: bool, pub(crate) _dropshot_crate: Option, + pub(crate) versions: Option>, } impl ChannelMetadata { @@ -288,6 +417,7 @@ impl ChannelMetadata { unpublished, deprecated, _dropshot_crate, + versions, } = self; if kind == MacroKind::Trait && _dropshot_crate.is_some() { @@ -324,6 +454,9 @@ impl ChannelMetadata { unpublished, deprecated, content_type: ValidContentType::ApplicationJson, + versions: versions + .map(|h| h.into_inner()) + .unwrap_or(VersionRange::All), }; Some(ValidatedChannelMetadata { inner }) diff --git a/dropshot_endpoint/tests/output/api_trait_basic.rs b/dropshot_endpoint/tests/output/api_trait_basic.rs index 726ae913..d655b950 100644 --- a/dropshot_endpoint/tests/output/api_trait_basic.rs +++ b/dropshot_endpoint/tests/output/api_trait_basic.rs @@ -73,6 +73,9 @@ mod my_trait_mod { dropshot::Method::GET, "application/json", "/xyz", + dropshot::ApiEndpointVersions::From( + semver::Version::new(1u64, 2u64, 3u64), + ), ); if let Err(error) = dropshot_api.register(endpoint_handler_xyz) { dropshot_errors.push(error); @@ -87,6 +90,7 @@ mod my_trait_mod { dropshot::Method::GET, "application/json", "/ws", + dropshot::ApiEndpointVersions::All, ); if let Err(error) = dropshot_api.register(endpoint_handler_ws) { dropshot_errors.push(error); @@ -156,6 +160,9 @@ mod my_trait_mod { dropshot::Method::GET, "application/json", "/xyz", + dropshot::ApiEndpointVersions::From( + semver::Version::new(1u64, 2u64, 3u64), + ), ); if let Err(error) = dropshot_api.register(endpoint_handler_xyz) { dropshot_errors.push(error); @@ -179,6 +186,7 @@ mod my_trait_mod { dropshot::Method::GET, "application/json", "/ws", + dropshot::ApiEndpointVersions::All, ); if let Err(error) = dropshot_api.register(endpoint_handler_ws) { dropshot_errors.push(error); diff --git a/dropshot_endpoint/tests/output/api_trait_operation_id.rs b/dropshot_endpoint/tests/output/api_trait_operation_id.rs index 9309b7f0..02e99e1e 100644 --- a/dropshot_endpoint/tests/output/api_trait_operation_id.rs +++ b/dropshot_endpoint/tests/output/api_trait_operation_id.rs @@ -72,6 +72,7 @@ pub mod my_trait_mod { dropshot::Method::GET, "application/json", "/xyz", + dropshot::ApiEndpointVersions::All, ); if let Err(error) = dropshot_api.register(endpoint_handler_xyz) { dropshot_errors.push(error); @@ -86,6 +87,7 @@ pub mod my_trait_mod { dropshot::Method::GET, "application/json", "/ws", + dropshot::ApiEndpointVersions::All, ); if let Err(error) = dropshot_api.register(endpoint_handler_ws) { dropshot_errors.push(error); @@ -155,6 +157,7 @@ pub mod my_trait_mod { dropshot::Method::GET, "application/json", "/xyz", + dropshot::ApiEndpointVersions::All, ); if let Err(error) = dropshot_api.register(endpoint_handler_xyz) { dropshot_errors.push(error); @@ -178,6 +181,7 @@ pub mod my_trait_mod { dropshot::Method::GET, "application/json", "/ws", + dropshot::ApiEndpointVersions::All, ); if let Err(error) = dropshot_api.register(endpoint_handler_ws) { dropshot_errors.push(error); diff --git a/dropshot_endpoint/tests/output/api_trait_with_custom_params.rs b/dropshot_endpoint/tests/output/api_trait_with_custom_params.rs index 7781d64d..cff5cfba 100644 --- a/dropshot_endpoint/tests/output/api_trait_with_custom_params.rs +++ b/dropshot_endpoint/tests/output/api_trait_with_custom_params.rs @@ -96,6 +96,7 @@ pub mod my_support_module { topspin::Method::GET, "application/json", "/xyz", + topspin::ApiEndpointVersions::All, ); if let Err(error) = dropshot_api.register(endpoint_handler_xyz) { dropshot_errors.push(error); @@ -105,7 +106,13 @@ pub mod my_support_module { let endpoint_handler_ws = topspin::ApiEndpoint::new_for_types::< (topspin::WebsocketUpgrade,), topspin::WebsocketEndpointResult, - >("handler_ws".to_string(), topspin::Method::GET, "application/json", "/ws"); + >( + "handler_ws".to_string(), + topspin::Method::GET, + "application/json", + "/ws", + topspin::ApiEndpointVersions::All, + ); if let Err(error) = dropshot_api.register(endpoint_handler_ws) { dropshot_errors.push(error); } @@ -198,6 +205,7 @@ pub mod my_support_module { topspin::Method::GET, "application/json", "/xyz", + topspin::ApiEndpointVersions::All, ); if let Err(error) = dropshot_api.register(endpoint_handler_xyz) { dropshot_errors.push(error); @@ -221,6 +229,7 @@ pub mod my_support_module { topspin::Method::GET, "application/json", "/ws", + topspin::ApiEndpointVersions::All, ); if let Err(error) = dropshot_api.register(endpoint_handler_ws) { dropshot_errors.push(error); diff --git a/dropshot_endpoint/tests/output/channel_operation_id.rs b/dropshot_endpoint/tests/output/channel_operation_id.rs index 16b95b6e..59491b7b 100644 --- a/dropshot_endpoint/tests/output/channel_operation_id.rs +++ b/dropshot_endpoint/tests/output/channel_operation_id.rs @@ -58,6 +58,7 @@ for dropshot::ApiEndpoint< dropshot::Method::GET, "application/json", "/my/ws/channel", + dropshot::ApiEndpointVersions::All, ) } } diff --git a/dropshot_endpoint/tests/output/channel_with_custom_params.rs b/dropshot_endpoint/tests/output/channel_with_custom_params.rs index f3adfe78..531b4281 100644 --- a/dropshot_endpoint/tests/output/channel_with_custom_params.rs +++ b/dropshot_endpoint/tests/output/channel_with_custom_params.rs @@ -70,6 +70,7 @@ for topspin::ApiEndpoint< topspin::Method::GET, "application/json", "/my/ws/channel", + topspin::ApiEndpointVersions::All, ) } } diff --git a/dropshot_endpoint/tests/output/channel_with_unnamed_params.rs b/dropshot_endpoint/tests/output/channel_with_unnamed_params.rs index d4aece24..5d18b2ec 100644 --- a/dropshot_endpoint/tests/output/channel_with_unnamed_params.rs +++ b/dropshot_endpoint/tests/output/channel_with_unnamed_params.rs @@ -80,6 +80,7 @@ for dropshot::ApiEndpoint< dropshot::Method::GET, "application/json", "/my/ws/channel", + dropshot::ApiEndpointVersions::All, ) } } diff --git a/dropshot_endpoint/tests/output/channel_with_versions.rs b/dropshot_endpoint/tests/output/channel_with_versions.rs new file mode 100644 index 00000000..a891febb --- /dev/null +++ b/dropshot_endpoint/tests/output/channel_with_versions.rs @@ -0,0 +1,64 @@ +const _: fn() = || { + struct NeedRequestContext( + as dropshot::RequestContextArgument>::Context, + ); +}; +const _: fn() = || { + trait TypeEq { + type This: ?Sized; + } + impl TypeEq for T { + type This = Self; + } + fn validate_websocket_connection_type() + where + T: ?Sized + TypeEq, + {} + validate_websocket_connection_type::(); +}; +#[allow(non_camel_case_types, missing_docs)] +///API Endpoint: my_channel +struct my_channel {} +#[allow(non_upper_case_globals, missing_docs)] +///API Endpoint: my_channel +const my_channel: my_channel = my_channel {}; +impl From +for dropshot::ApiEndpoint< + as dropshot::RequestContextArgument>::Context, +> { + fn from(_: my_channel) -> Self { + #[allow(clippy::unused_async)] + async fn my_channel( + _rqctx: RequestContext<()>, + _conn: WebsocketConnection, + ) -> WebsocketChannelResult { + Ok(()) + } + const _: fn() = || { + fn future_endpoint_must_be_send(_t: T) {} + fn check_future_bounds( + arg0: RequestContext<()>, + __dropshot_websocket: WebsocketConnection, + ) { + future_endpoint_must_be_send(my_channel(arg0, __dropshot_websocket)); + } + }; + async fn my_channel_adapter( + arg0: RequestContext<()>, + __dropshot_websocket: dropshot::WebsocketUpgrade, + ) -> dropshot::WebsocketEndpointResult { + __dropshot_websocket + .handle(move |__dropshot_websocket: WebsocketConnection| async move { + my_channel(arg0, __dropshot_websocket).await + }) + } + dropshot::ApiEndpoint::new( + "my_channel".to_string(), + my_channel_adapter, + dropshot::Method::GET, + "application/json", + "/my/ws/channel", + dropshot::ApiEndpointVersions::Until(semver::Version::new(1u64, 2u64, 3u64)), + ) + } +} diff --git a/dropshot_endpoint/tests/output/endpoint_basic.rs b/dropshot_endpoint/tests/output/endpoint_basic.rs index b967d559..916d25eb 100644 --- a/dropshot_endpoint/tests/output/endpoint_basic.rs +++ b/dropshot_endpoint/tests/output/endpoint_basic.rs @@ -59,6 +59,7 @@ for dropshot::ApiEndpoint< dropshot::Method::GET, "application/json", "/a/b/c", + dropshot::ApiEndpointVersions::All, ) } } diff --git a/dropshot_endpoint/tests/output/endpoint_content_type.rs b/dropshot_endpoint/tests/output/endpoint_content_type.rs index 46ccdfe7..97a305d4 100644 --- a/dropshot_endpoint/tests/output/endpoint_content_type.rs +++ b/dropshot_endpoint/tests/output/endpoint_content_type.rs @@ -59,6 +59,7 @@ for dropshot::ApiEndpoint< dropshot::Method::POST, "application/x-www-form-urlencoded", "/a/b/c", + dropshot::ApiEndpointVersions::All, ) } } diff --git a/dropshot_endpoint/tests/output/endpoint_context_fully_qualified_names.rs b/dropshot_endpoint/tests/output/endpoint_context_fully_qualified_names.rs index 7fe68014..c126c927 100644 --- a/dropshot_endpoint/tests/output/endpoint_context_fully_qualified_names.rs +++ b/dropshot_endpoint/tests/output/endpoint_context_fully_qualified_names.rs @@ -67,6 +67,7 @@ for dropshot::ApiEndpoint< dropshot::Method::GET, "application/json", "/a/b/c", + dropshot::ApiEndpointVersions::All, ) } } diff --git a/dropshot_endpoint/tests/output/endpoint_operation_id.rs b/dropshot_endpoint/tests/output/endpoint_operation_id.rs index 586eb3b3..d560ba6c 100644 --- a/dropshot_endpoint/tests/output/endpoint_operation_id.rs +++ b/dropshot_endpoint/tests/output/endpoint_operation_id.rs @@ -59,6 +59,7 @@ for dropshot::ApiEndpoint< dropshot::Method::GET, "application/json", "/a/b/c", + dropshot::ApiEndpointVersions::All, ) } } diff --git a/dropshot_endpoint/tests/output/endpoint_pub_crate.rs b/dropshot_endpoint/tests/output/endpoint_pub_crate.rs index 4d5fb1fa..a6cc141a 100644 --- a/dropshot_endpoint/tests/output/endpoint_pub_crate.rs +++ b/dropshot_endpoint/tests/output/endpoint_pub_crate.rs @@ -67,6 +67,7 @@ for dropshot::ApiEndpoint< dropshot::Method::GET, "application/json", "/a/b/c", + dropshot::ApiEndpointVersions::All, ) } } diff --git a/dropshot_endpoint/tests/output/endpoint_weird_but_ok_arg_types_1.rs b/dropshot_endpoint/tests/output/endpoint_weird_but_ok_arg_types_1.rs index 00bd3d7d..7a84672d 100644 --- a/dropshot_endpoint/tests/output/endpoint_weird_but_ok_arg_types_1.rs +++ b/dropshot_endpoint/tests/output/endpoint_weird_but_ok_arg_types_1.rs @@ -84,6 +84,7 @@ for dropshot::ApiEndpoint< dropshot::Method::GET, "application/json", "/a/b/c", + dropshot::ApiEndpointVersions::All, ) .summary("handle \"xyz\" requests") } diff --git a/dropshot_endpoint/tests/output/endpoint_weird_but_ok_arg_types_2.rs b/dropshot_endpoint/tests/output/endpoint_weird_but_ok_arg_types_2.rs index 294fcb19..180943b1 100644 --- a/dropshot_endpoint/tests/output/endpoint_weird_but_ok_arg_types_2.rs +++ b/dropshot_endpoint/tests/output/endpoint_weird_but_ok_arg_types_2.rs @@ -64,6 +64,7 @@ for dropshot::ApiEndpoint< dropshot::Method::GET, "application/json", "/a/b/c", + dropshot::ApiEndpointVersions::All, ) .summary("handle \"xyz\" requests") } diff --git a/dropshot_endpoint/tests/output/endpoint_with_custom_params.rs b/dropshot_endpoint/tests/output/endpoint_with_custom_params.rs index ff0d26c4..31e9fa89 100644 --- a/dropshot_endpoint/tests/output/endpoint_with_custom_params.rs +++ b/dropshot_endpoint/tests/output/endpoint_with_custom_params.rs @@ -79,6 +79,7 @@ for topspin::ApiEndpoint< topspin::Method::GET, "application/json", "/a/b/c", + topspin::ApiEndpointVersions::All, ) } } diff --git a/dropshot_endpoint/tests/output/endpoint_with_doc.rs b/dropshot_endpoint/tests/output/endpoint_with_doc.rs index febc155e..a117dd32 100644 --- a/dropshot_endpoint/tests/output/endpoint_with_doc.rs +++ b/dropshot_endpoint/tests/output/endpoint_with_doc.rs @@ -62,6 +62,7 @@ for dropshot::ApiEndpoint< dropshot::Method::GET, "application/json", "/a/b/c", + dropshot::ApiEndpointVersions::All, ) .summary("handle \"xyz\" requests") } diff --git a/dropshot_endpoint/tests/output/endpoint_with_empty_where_clause.rs b/dropshot_endpoint/tests/output/endpoint_with_empty_where_clause.rs index b967d559..916d25eb 100644 --- a/dropshot_endpoint/tests/output/endpoint_with_empty_where_clause.rs +++ b/dropshot_endpoint/tests/output/endpoint_with_empty_where_clause.rs @@ -59,6 +59,7 @@ for dropshot::ApiEndpoint< dropshot::Method::GET, "application/json", "/a/b/c", + dropshot::ApiEndpointVersions::All, ) } } diff --git a/dropshot_endpoint/tests/output/endpoint_with_query.rs b/dropshot_endpoint/tests/output/endpoint_with_query.rs index 9c0b6208..704f00d1 100644 --- a/dropshot_endpoint/tests/output/endpoint_with_query.rs +++ b/dropshot_endpoint/tests/output/endpoint_with_query.rs @@ -67,6 +67,7 @@ for dropshot::ApiEndpoint< dropshot::Method::GET, "application/json", "/a/b/c", + dropshot::ApiEndpointVersions::All, ) } } diff --git a/dropshot_endpoint/tests/output/endpoint_with_tags.rs b/dropshot_endpoint/tests/output/endpoint_with_tags.rs index cd3c50e0..68c36c1f 100644 --- a/dropshot_endpoint/tests/output/endpoint_with_tags.rs +++ b/dropshot_endpoint/tests/output/endpoint_with_tags.rs @@ -59,6 +59,7 @@ for dropshot::ApiEndpoint< dropshot::Method::GET, "application/json", "/a/b/c", + dropshot::ApiEndpointVersions::All, ) .tag("stuff") .tag("things") diff --git a/dropshot_endpoint/tests/output/endpoint_with_unnamed_params.rs b/dropshot_endpoint/tests/output/endpoint_with_unnamed_params.rs index d4568b8c..63419caf 100644 --- a/dropshot_endpoint/tests/output/endpoint_with_unnamed_params.rs +++ b/dropshot_endpoint/tests/output/endpoint_with_unnamed_params.rs @@ -90,6 +90,7 @@ for dropshot::ApiEndpoint< dropshot::Method::GET, "application/json", "/test", + dropshot::ApiEndpointVersions::All, ) } } diff --git a/dropshot_endpoint/tests/output/endpoint_with_versions_all.rs b/dropshot_endpoint/tests/output/endpoint_with_versions_all.rs new file mode 100644 index 00000000..fe36fd4f --- /dev/null +++ b/dropshot_endpoint/tests/output/endpoint_with_versions_all.rs @@ -0,0 +1,67 @@ +const _: fn() = || { + struct NeedRequestContext( + as dropshot::RequestContextArgument>::Context, + ); +}; +const _: fn() = || { + trait ResultTrait { + type T; + type E; + } + impl ResultTrait for Result + where + TT: dropshot::HttpResponse, + { + type T = TT; + type E = EE; + } + struct NeedHttpResponse( + as ResultTrait>::T, + ); + trait TypeEq { + type This: ?Sized; + } + impl TypeEq for T { + type This = Self; + } + fn validate_result_error_type() + where + T: ?Sized + TypeEq, + {} + validate_result_error_type::< + as ResultTrait>::E, + >(); +}; +#[allow(non_camel_case_types, missing_docs)] +///API Endpoint: handler_xyz +struct handler_xyz {} +#[allow(non_upper_case_globals, missing_docs)] +///API Endpoint: handler_xyz +const handler_xyz: handler_xyz = handler_xyz {}; +impl From +for dropshot::ApiEndpoint< + as dropshot::RequestContextArgument>::Context, +> { + fn from(_: handler_xyz) -> Self { + #[allow(clippy::unused_async)] + async fn handler_xyz( + _: RequestContext<()>, + ) -> Result { + Ok(()) + } + const _: fn() = || { + fn future_endpoint_must_be_send(_t: T) {} + fn check_future_bounds(arg0: RequestContext<()>) { + future_endpoint_must_be_send(handler_xyz(arg0)); + } + }; + dropshot::ApiEndpoint::new( + "handler_xyz".to_string(), + handler_xyz, + dropshot::Method::GET, + "application/json", + "/test", + dropshot::ApiEndpointVersions::All, + ) + } +} diff --git a/dropshot_endpoint/tests/output/endpoint_with_versions_from.rs b/dropshot_endpoint/tests/output/endpoint_with_versions_from.rs new file mode 100644 index 00000000..79aae906 --- /dev/null +++ b/dropshot_endpoint/tests/output/endpoint_with_versions_from.rs @@ -0,0 +1,67 @@ +const _: fn() = || { + struct NeedRequestContext( + as dropshot::RequestContextArgument>::Context, + ); +}; +const _: fn() = || { + trait ResultTrait { + type T; + type E; + } + impl ResultTrait for Result + where + TT: dropshot::HttpResponse, + { + type T = TT; + type E = EE; + } + struct NeedHttpResponse( + as ResultTrait>::T, + ); + trait TypeEq { + type This: ?Sized; + } + impl TypeEq for T { + type This = Self; + } + fn validate_result_error_type() + where + T: ?Sized + TypeEq, + {} + validate_result_error_type::< + as ResultTrait>::E, + >(); +}; +#[allow(non_camel_case_types, missing_docs)] +///API Endpoint: handler_xyz +struct handler_xyz {} +#[allow(non_upper_case_globals, missing_docs)] +///API Endpoint: handler_xyz +const handler_xyz: handler_xyz = handler_xyz {}; +impl From +for dropshot::ApiEndpoint< + as dropshot::RequestContextArgument>::Context, +> { + fn from(_: handler_xyz) -> Self { + #[allow(clippy::unused_async)] + async fn handler_xyz( + _: RequestContext<()>, + ) -> Result { + Ok(()) + } + const _: fn() = || { + fn future_endpoint_must_be_send(_t: T) {} + fn check_future_bounds(arg0: RequestContext<()>) { + future_endpoint_must_be_send(handler_xyz(arg0)); + } + }; + dropshot::ApiEndpoint::new( + "handler_xyz".to_string(), + handler_xyz, + dropshot::Method::GET, + "application/json", + "/test", + dropshot::ApiEndpointVersions::From(semver::Version::new(1u64, 2u64, 3u64)), + ) + } +} diff --git a/dropshot_endpoint/tests/output/endpoint_with_versions_from_until.rs b/dropshot_endpoint/tests/output/endpoint_with_versions_from_until.rs new file mode 100644 index 00000000..3c290156 --- /dev/null +++ b/dropshot_endpoint/tests/output/endpoint_with_versions_from_until.rs @@ -0,0 +1,71 @@ +const _: fn() = || { + struct NeedRequestContext( + as dropshot::RequestContextArgument>::Context, + ); +}; +const _: fn() = || { + trait ResultTrait { + type T; + type E; + } + impl ResultTrait for Result + where + TT: dropshot::HttpResponse, + { + type T = TT; + type E = EE; + } + struct NeedHttpResponse( + as ResultTrait>::T, + ); + trait TypeEq { + type This: ?Sized; + } + impl TypeEq for T { + type This = Self; + } + fn validate_result_error_type() + where + T: ?Sized + TypeEq, + {} + validate_result_error_type::< + as ResultTrait>::E, + >(); +}; +#[allow(non_camel_case_types, missing_docs)] +///API Endpoint: handler_xyz +struct handler_xyz {} +#[allow(non_upper_case_globals, missing_docs)] +///API Endpoint: handler_xyz +const handler_xyz: handler_xyz = handler_xyz {}; +impl From +for dropshot::ApiEndpoint< + as dropshot::RequestContextArgument>::Context, +> { + fn from(_: handler_xyz) -> Self { + #[allow(clippy::unused_async)] + async fn handler_xyz( + _: RequestContext<()>, + ) -> Result { + Ok(()) + } + const _: fn() = || { + fn future_endpoint_must_be_send(_t: T) {} + fn check_future_bounds(arg0: RequestContext<()>) { + future_endpoint_must_be_send(handler_xyz(arg0)); + } + }; + dropshot::ApiEndpoint::new( + "handler_xyz".to_string(), + handler_xyz, + dropshot::Method::GET, + "application/json", + "/test", + dropshot::ApiEndpointVersions::from_until( + semver::Version::new(1u64, 2u64, 3u64), + semver::Version::new(4u64, 5u64, 6u64), + ) + .unwrap(), + ) + } +} diff --git a/dropshot_endpoint/tests/output/endpoint_with_versions_until.rs b/dropshot_endpoint/tests/output/endpoint_with_versions_until.rs new file mode 100644 index 00000000..49b418a5 --- /dev/null +++ b/dropshot_endpoint/tests/output/endpoint_with_versions_until.rs @@ -0,0 +1,67 @@ +const _: fn() = || { + struct NeedRequestContext( + as dropshot::RequestContextArgument>::Context, + ); +}; +const _: fn() = || { + trait ResultTrait { + type T; + type E; + } + impl ResultTrait for Result + where + TT: dropshot::HttpResponse, + { + type T = TT; + type E = EE; + } + struct NeedHttpResponse( + as ResultTrait>::T, + ); + trait TypeEq { + type This: ?Sized; + } + impl TypeEq for T { + type This = Self; + } + fn validate_result_error_type() + where + T: ?Sized + TypeEq, + {} + validate_result_error_type::< + as ResultTrait>::E, + >(); +}; +#[allow(non_camel_case_types, missing_docs)] +///API Endpoint: handler_xyz +struct handler_xyz {} +#[allow(non_upper_case_globals, missing_docs)] +///API Endpoint: handler_xyz +const handler_xyz: handler_xyz = handler_xyz {}; +impl From +for dropshot::ApiEndpoint< + as dropshot::RequestContextArgument>::Context, +> { + fn from(_: handler_xyz) -> Self { + #[allow(clippy::unused_async)] + async fn handler_xyz( + _: RequestContext<()>, + ) -> Result { + Ok(()) + } + const _: fn() = || { + fn future_endpoint_must_be_send(_t: T) {} + fn check_future_bounds(arg0: RequestContext<()>) { + future_endpoint_must_be_send(handler_xyz(arg0)); + } + }; + dropshot::ApiEndpoint::new( + "handler_xyz".to_string(), + handler_xyz, + dropshot::Method::GET, + "application/json", + "/test", + dropshot::ApiEndpointVersions::Until(semver::Version::new(1u64, 2u64, 3u64)), + ) + } +}