Skip to content

Commit

Permalink
some refactoring; add a common impl; much documentation
Browse files Browse the repository at this point in the history
  • Loading branch information
davepacheco committed Sep 26, 2024
1 parent 4cc1d1d commit 8d4535e
Show file tree
Hide file tree
Showing 6 changed files with 475 additions and 136 deletions.
180 changes: 148 additions & 32 deletions dropshot/examples/versioning.rs
Original file line number Diff line number Diff line change
@@ -1,29 +1,121 @@
// Copyright 2024 Oxide Computer Company

//! Example using API versioning
//!
//! This example defines a bunch of API versions:
//!
//! - Versions 1.0.0 through 1.0.3 contain an endpoint `GET /` that returns a
//! `Value` type with just one field: `s`.
//! - Versions 1.0.5 and later contain an endpoint `GET /` that returns a
//! `Value` type with two fields: `s` and `number`.
//!
//! 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 `Value`,
//! and that the `Value` type only has the one field `s`.
//!
//! You can generate the OpenAPI spec for 1.0.5 and see that the corresponding
//! `Value` type has the extra field `number`, as expected.
//!
//! You can generate the OpenAPI spec for any other version. You'll see that
//! 0.9.0, for example, has no operations and no `Value` type.
//!
//! ## 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
//! {
//! "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
//! {
//! "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 (or 1.0.4), there is no endpoint at `/`, so we
//! get a 404:
//!
//! ```text
//! $ curl -i -H 'dropshot-demo-version: 0.9.0' http://127.0.0.1:12345
//! 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
//! {"s":"hello from an early v1"}
//! ```
//!
//! If we provide version 1.0.5, we get the later version that we defined, with
//! a different response body type:
//!
//! ```text
//! $ curl -H 'dropshot-demo-version: 1.0.5' http://127.0.0.1:12345
//! {"s":"hello from a LATE v1","number":12}
//! ```

use dropshot::endpoint;
use dropshot::ApiDescription;
use dropshot::ClientSpecifiesVersionInHeader;
use dropshot::ConfigDropshot;
use dropshot::ConfigLogging;
use dropshot::ConfigLoggingLevel;
use dropshot::DynamicVersionPolicy;
use dropshot::HttpError;
use dropshot::HttpResponseOk;
use dropshot::HttpServerStarter;
use dropshot::RequestContext;
use dropshot::VersionPolicy;
use hyper::Body;
use hyper::Request;
use http::HeaderName;
use schemars::JsonSchema;
use semver::Version;
use serde::Serialize;
use slog::Logger;

#[tokio::main]
async fn main() -> Result<(), String> {
// See dropshot/examples/basic.rs for more details on these pieces.
let config_dropshot: ConfigDropshot = Default::default();
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
Expand All @@ -34,32 +126,56 @@ async fn main() -> Result<(), String> {
api.register(v1::versioned_get).unwrap();
api.register(v2::versioned_get).unwrap();

let api_context = ();
let server = HttpServerStarter::new_with_versioning(
&config_dropshot,
api,
api_context,
&log,
None,
VersionPolicy::Dynamic(Box::new(BasicVersionPolicy {})),
)
.map_err(|error| format!("failed to create server: {}", error))?
.start();

server.await
}

/// This impl of `DynamicVersionPolicy` tells Dropshot how to determine the
/// appropriate version number for a request.
#[derive(Debug)]
struct BasicVersionPolicy {}
impl DynamicVersionPolicy for BasicVersionPolicy {
fn request_extract_version(
&self,
request: &Request<Body>,
_log: &Logger,
) -> Result<Version, HttpError> {
dropshot::parse_header(request.headers(), "dropshot-demo-version")
// 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 will say that the client always specifies the
// version using the "dropshot-demo-version" header. 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).
let header_name = "dropshot-demo-version"
.parse::<HeaderName>()
.map_err(|_| String::from("demo header name was not valid"))?;
let version_impl = ClientSpecifiesVersionInHeader::new(header_name);
let version_policy = VersionPolicy::Dynamic(Box::new(version_impl));
let server = HttpServerStarter::new_with_versioning(
&config_dropshot,
api,
api_context,
&log,
None,
version_policy,
)
.map_err(|error| format!("failed to create server: {}", error))?
.start();

server.await
} else {
Err(String::from("unknown subcommand"))
}
}

Expand Down
142 changes: 103 additions & 39 deletions dropshot/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,7 @@
//!
//! // Optional fields
//! tags = [ "all", "your", "OpenAPI", "tags" ],
//! versions = ..
//! }]
//! ```
//!
Expand All @@ -326,6 +327,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
//!
Expand Down Expand Up @@ -697,6 +701,101 @@
//! 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 *after* 2.0.0, present in all previous versions
//! versions = "2.0.0"...
//!
//! // introduced in 1.0.0, removed after 2.0.0
//! // (present only in versions 1.0.0 through 2.0.0, inclusive)
//! 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<MyArg>`. 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
Expand Down Expand Up @@ -764,6 +863,7 @@ mod schema_util;
mod server;
mod to_map;
mod type_util;
mod versioning;
mod websocket;

pub mod test_util;
Expand Down Expand Up @@ -836,11 +936,12 @@ pub use pagination::PaginationOrder;
pub use pagination::PaginationParams;
pub use pagination::ResultsPage;
pub use pagination::WhichPage;
pub use server::DynamicVersionPolicy;
pub use server::ServerContext;
pub use server::ShutdownWaitFuture;
pub use server::VersionPolicy;
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;
Expand All @@ -855,40 +956,3 @@ extern crate dropshot_endpoint;
pub use dropshot_endpoint::api_description;
pub use dropshot_endpoint::channel;
pub use dropshot_endpoint::endpoint;

use std::str::FromStr;

/// Parses a required header out of a request (producing useful error messages
/// for all failure modes)
pub fn parse_header<T>(
headers: &http::HeaderMap,
header_name: &str,
) -> Result<T, HttpError>
where
T: FromStr,
<T as 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::<T>().map_err(|e| {
HttpError::for_bad_request(
None,
format!("bad value for header {:?}: {}: {}", header_name, e, v_str),
)
})
}
Loading

0 comments on commit 8d4535e

Please sign in to comment.