Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for HEAD endpoints; add example accepting Range: header #776

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ https://github.com/oxidecomputer/dropshot/compare/v0.9.0\...HEAD[Full list of co
=== Other notable Changes

* https://github.com/oxidecomputer/dropshot/pull/660[#660] The `x-dropshot-pagination` extension used to be simply the value `true`. Now it is an object with a field, `required`, that is an array of parameters that are mandatory on the first invocation.
* https://github.com/oxidecomputer/dropshot/pull/776[#776] Dropshot endpoints can now specify `method = HEAD`.

== 0.9.0 (released 2023-01-20)

Expand Down
7 changes: 7 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions dropshot/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ features = [ "uuid1" ]
async-channel = "1.9.0"
buf-list = "1.0.3"
expectorate = "1.0.7"
http-range-header = "0.4.0"
hyper-rustls = "0.24.1"
hyper-staticfile = "0.9"
lazy_static = "1.4.0"
Expand Down
198 changes: 198 additions & 0 deletions dropshot/examples/request-range.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
// Copyright 2023 Oxide Computer Company

//! Example use of Dropshot supporting requests with an HTTP range headers
//!
//! This example is based on the "basic.rs" one. See that one for more detailed
//! comments on the common code.

use dropshot::endpoint;
use dropshot::ApiDescription;
use dropshot::ConfigDropshot;
use dropshot::ConfigLogging;
use dropshot::ConfigLoggingLevel;
use dropshot::HttpError;
use dropshot::HttpServerStarter;
use dropshot::RequestContext;
use http::header;
use http::status::StatusCode;
use hyper::Body;
use hyper::Response;
use std::ops::RangeInclusive;

#[allow(clippy::let_unit_value)] // suppress warnings on our empty api_context
#[tokio::main]
async fn main() -> Result<(), String> {
let config_dropshot: ConfigDropshot = Default::default();
let config_logging =
ConfigLogging::StderrTerminal { level: ConfigLoggingLevel::Info };
let log = config_logging
.to_logger("example-basic")
.map_err(|error| format!("failed to create logger: {}", error))?;
let mut api = ApiDescription::new();
api.register(example_get_with_range_support).unwrap();
api.register(example_head_with_range_support).unwrap();

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

/// Return "lorem ipsum" text, optionally with a range provided by the client.
///
/// Does not support multiple range specifications (and will therefore never
/// send `content-type: multipart/byteranges` responses).
#[endpoint {
method = GET,
path = "/lorem-ipsum",
}]
async fn example_get_with_range_support(
rqctx: RequestContext<()>,
) -> Result<Response<Body>, HttpError> {
get_or_head_with_range_support(
rqctx,
LOREM_IPSUM.len() as u64,
&|maybe_range| {
match maybe_range {
None => Body::from(LOREM_IPSUM),
Some(range) => {
// We should only be called with ranges that fit into
// `0..LOREM_IPSUM.len()` (the data length we pass above),
// so we'll panic here if we get an out of bounds range.
let data = LOREM_IPSUM
.get(*range.start() as usize..=*range.end() as usize)
.expect("invalid range returned by validate");
Body::from(data)
}
}
},
)
.await
}

/// Implement `HEAD` for our download endpoint above.
#[endpoint {
method = HEAD,
path = "/lorem-ipsum",
}]
async fn example_head_with_range_support(
rqctx: RequestContext<()>,
) -> Result<Response<Body>, HttpError> {
get_or_head_with_range_support(rqctx, LOREM_IPSUM.len() as u64, &|_| {
Body::empty()
})
.await
}

async fn get_or_head_with_range_support(
rqctx: RequestContext<()>,
data_len: u64,
make_body: &(dyn Fn(Option<RangeInclusive<u64>>) -> Body + Send + Sync),
) -> Result<Response<Body>, HttpError> {
let headers = rqctx.request.headers();

// TODO-correctness: We are not supporting the `If-Range:` conditional
// header; see RFC 7233 § 3.2.

let Some(range) = headers.get(header::RANGE) else {
// No range specification; return the full data, but set the
// `accept-ranges` header to indicate the client _could_ have asked for
// a subset.
return Ok(Response::builder()
.status(StatusCode::OK)
.header(header::ACCEPT_RANGES, "bytes")
.header(header::CONTENT_TYPE, "text/plain")
.header(header::CONTENT_LENGTH, data_len.to_string())
.body(make_body(None))?);
};

// Parse the range header value.
let range = match range
.to_str()
.map_err(|err| format!("invalid range header: {err}"))
.and_then(|range| {
http_range_header::parse_range_header(range)
.map_err(|err| format!("invalid range header: {err}"))
}) {
Ok(range) => range,
Err(err) => return range_not_satisfiable(data_len, err),
};

// Ensure the requested ranges are valid for our data.
let mut ranges = match range.validate(data_len) {
Ok(ranges) => ranges,
Err(err) => {
return range_not_satisfiable(data_len, err.to_string());
}
};

// If the client requested multiple ranges, we ought to send back a
// `multipart/byteranges` payload with delimiters and separate content-type
// / content-range headers on each part (see RFC 7233 § 4.1). We do not
// support that in this example, so we'll send back a RANGE_NOT_SATISFIABLE
// if more than one range was requested. This seems to be allowed by a
// loose reading of RFC 7233:
//
// > The 416 (Range Not Satisfiable) status code indicates that none of
// > the ranges in the request's Range header field (Section 3.1) overlap
// > the current extent of the selected resource or that the set of ranges
// > requested has been rejected due to invalid ranges or an excessive
// > request of small or overlapping ranges.
//
// if we consider two or more ranges of any size to be "an excessive request
// of small or overlapping ranges".
if ranges.len() > 1 {
return range_not_satisfiable(
data_len,
"server only supports a single range".to_string(),
);
}
let range = ranges.remove(0);

// Call `make_body` with the requested range, and trust that it returns a
// body of exactly the requested length.
let content_length = range.end() - range.start() + 1;
let content_range =
format!("bytes {}-{}/{data_len}", range.start(), range.end());
let body = make_body(Some(range));

// Send back an HTTP 206 (Partial Content) with the required headers
// (content-type, content-range, content-length). Also set the accept-ranges
// header; it's unclear whether this is expected in a 206 response.
Ok(Response::builder()
.status(StatusCode::PARTIAL_CONTENT)
.header(header::ACCEPT_RANGES, "bytes")
.header(header::CONTENT_TYPE, "text/plain")
.header(header::CONTENT_LENGTH, content_length.to_string())
.header(header::CONTENT_RANGE, content_range)
.body(body)?)
}

fn range_not_satisfiable(
data_len: u64,
err: String,
) -> Result<Response<Body>, HttpError> {
// TODO: It's weird that we're returning `Ok(_)` with a status code that
// indicates an error (RANGE_NOT_SATISFIABLE is HTTP 416), but we need to
// set the above headers on the response, which HttpError currently doesn't
// support. We build a custom Response instead.
//
// Replace this with a header-bearing HttpError once we can:
// https://github.com/oxidecomputer/dropshot/issues/644
Ok(Response::builder()
.status(StatusCode::RANGE_NOT_SATISFIABLE)
.header(header::ACCEPT_RANGES, "bytes")
.header(header::CONTENT_RANGE, format!("bytes */{}", data_len))
.body(err.into())?)
}

const LOREM_IPSUM: &[u8] = b"\
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod \
tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, \
quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo \
consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse \
cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat \
non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\
";
2 changes: 2 additions & 0 deletions dropshot_endpoint/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ enum MethodType {
POST,
PUT,
OPTIONS,
HEAD,
}

impl MethodType {
Expand All @@ -41,6 +42,7 @@ impl MethodType {
MethodType::POST => "POST",
MethodType::PUT => "PUT",
MethodType::OPTIONS => "OPTIONS",
MethodType::HEAD => "HEAD",
}
}
}
Expand Down