diff --git a/Cargo.lock b/Cargo.lock index 2f636ab98..f82ba50de 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -113,6 +113,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" +[[package]] +name = "assert_matches" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b34d609dfbaf33d6889b2b7106d3ca345eacad44200913df5ba02bfd31d2ba9" + [[package]] name = "async-stream" version = "0.3.3" @@ -298,6 +304,18 @@ dependencies = [ "serde", ] +[[package]] +name = "bstr" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fca0852af221f458706eb0725c03e4ed6c46af9ac98e6a689d5e634215d594dd" +dependencies = [ + "memchr", + "once_cell", + "regex-automata", + "serde", +] + [[package]] name = "bumpalo" version = "3.11.0" @@ -648,7 +666,7 @@ version = "1.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "22813a6dc45b335f9bade10bf7271dc477e81113e89eb251a0bc2a8a81c536e1" dependencies = [ - "bstr", + "bstr 0.2.17", "csv-core", "itoa 0.4.8", "ryu", @@ -1537,6 +1555,48 @@ dependencies = [ "indexmap", ] +[[package]] +name = "phf" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928c6535de93548188ef63bb7c4036bd415cd8f36ad25af44b9789b2ee72a48c" +dependencies = [ + "phf_macros", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1181c94580fa345f50f19d738aaa39c0ed30a600d95cb2d3e23f94266f14fbf" +dependencies = [ + "phf_shared", + "rand", +] + +[[package]] +name = "phf_macros" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92aacdc5f16768709a569e913f7451034034178b05bdc8acda226659a3dccc66" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "phf_shared" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1fb5f6f826b772a8d4c0394209441e7d37cbbb967ae9c7e0e8134365c9ee676" +dependencies = [ + "siphasher", +] + [[package]] name = "pin-project" version = "1.0.12" @@ -1731,6 +1791,22 @@ dependencies = [ "storage-types", ] +[[package]] +name = "protocol-http" +version = "0.3.0" +dependencies = [ + "arrayvec 0.7.2", + "assert_matches", + "bstr 1.0.1", + "bytes 1.2.1", + "httparse", + "logger", + "phf", + "protocol-common", + "thiserror", + "urlencoding", +] + [[package]] name = "protocol-memcache" version = "0.3.0" @@ -2224,6 +2300,12 @@ dependencies = [ "time 0.3.14", ] +[[package]] +name = "siphasher" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bd3e3206899af3f8b12af284fafc038cc1dc2b41d1b89dd17297221c5d225de" + [[package]] name = "slab" version = "0.4.7" @@ -2723,6 +2805,12 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "urlencoding" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8db7427f936968176eaa7cdf81b7f98b980b18495ec28f1b5791ac3bfe3eea9" + [[package]] name = "vec_map" version = "0.8.2" diff --git a/Cargo.toml b/Cargo.toml index 8499cf791..689c52c0f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,6 +19,7 @@ members = [ "src/net", "src/protocol/admin", "src/protocol/common", + "src/protocol/http", "src/protocol/memcache", "src/protocol/ping", "src/protocol/resp", @@ -38,22 +39,26 @@ members = [ [workspace.dependencies] ahash = "0.8.0" +arrayvec = "0.7.2" backtrace = "0.3.66" bitvec = "1.0.1" blake3 = "1.3.1" boring = "2.1.0" boring-sys = "2.1.0" +bstr = "1.0.1" bytes = "1.2.1" clap = "2.33.3" crossbeam-channel = "0.5.6" crossbeam-queue = "0.3.5" foreign-types-shared = "0.3.1" +httparse = "1.8.0" libc = "0.2.134" log = "0.4.17" memmap2 = "0.2.2" metrohash = "1.0.6" mio = "0.8.4" nom = "5.1.2" +phf = "0.11.1" proc-macro2 = "1.0.46" quote = "1.0.21" rand = "0.8.5" @@ -70,6 +75,7 @@ thiserror = "1.0.24" tiny_http = "0.11.0" toml = "0.5.9" twox-hash = { version = "1.6.3", default-features = false } +urlencoding = "2.1.2" zookeeper = "0.6.1" [profile.release] diff --git a/src/protocol/http/Cargo.toml b/src/protocol/http/Cargo.toml new file mode 100644 index 000000000..033f1061f --- /dev/null +++ b/src/protocol/http/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "protocol-http" +version = { workspace = true } +edition = { workspace = true } +license = { workspace = true } +homepage = { workspace = true } +repository = { workspace = true } + +[dependencies] +arrayvec = { workspace = true } +bytes = { workspace = true } +bstr = { workspace = true } +httparse = { workspace = true } +phf = { workspace = true, features = ["macros"] } +thiserror = { workspace = true } +urlencoding = { workspace = true } + +protocol-common = { path = "../common" } +logger = { path = "../../logger" } + +[dev-dependencies] +assert_matches = "1.5.0" diff --git a/src/protocol/http/src/error.rs b/src/protocol/http/src/error.rs new file mode 100644 index 000000000..e397d9451 --- /dev/null +++ b/src/protocol/http/src/error.rs @@ -0,0 +1,62 @@ +// Copyright 2022 Twitter, Inc. +// Licensed under the Apache License, Version 2.0 +// http://www.apache.org/licenses/LICENSE-2.0 + +use crate::Response; + +#[derive(Debug, Error)] +pub enum Error { + #[error("unable to parse request")] + Unparseable(#[from] httparse::Error), + #[error("Content-Length header was invalid")] + BadContentLength, + #[error("Content-Length header was missing")] + MissingContentLength, + #[error("method was unsupported")] + BadRequestMethod, + + /// Contains the number of additional bytes needed to parse the rest of the + /// request, if known. + #[error("not enough data present to parse the whole request")] + PartialRequest(Option), + + #[error("an internal error occurred: {0}")] + InternalError(&'static str), +} + +impl Error { + pub fn to_response(&self) -> Response { + match self { + Self::Unparseable(e) => Response::builder(400) + .should_close(true) + .header("Content-Type", b"text/plain") + .body(format!("Unable to parse request: {}", e).as_bytes()), + Self::BadRequestMethod => Response::builder(405) + .should_close(true) + .header("Content-Type", b"text/plain") + .body( + format!("Unsupported method, only GET, PUT, and DELETE are supported") + .as_bytes(), + ), + Self::BadContentLength => Response::builder(400) + .should_close(true) + .header("Content-Type", b"text/plain") + .body(format!("Content-Length header was invalid").as_bytes()), + Self::MissingContentLength => Response::builder(411) + .should_close(true) + .header("Content-Type", b"text/plain") + .body( + format!("A Content-Length header is required for all PUT requests").as_bytes(), + ), + Self::InternalError(message) => Response::builder(500) + .should_close(true) + .header("Content-Type", b"text/plain") + .body(message.as_bytes()), + + Self::PartialRequest(_) => Response::builder(500) + .should_close(true) + .header("Content-Type", b"text/plain") + .body(b"internal server error"), + } + } +} diff --git a/src/protocol/http/src/lib.rs b/src/protocol/http/src/lib.rs new file mode 100644 index 000000000..56d892e33 --- /dev/null +++ b/src/protocol/http/src/lib.rs @@ -0,0 +1,38 @@ +// Copyright 2022 Twitter, Inc. +// Licensed under the Apache License, Version 2.0 +// http://www.apache.org/licenses/LICENSE-2.0 + +//! HTTP protocol for pelikan. +//! +//! This crate contains definitions for a basic REST protocol for interacting +//! with a cache. It supports just 3 operations: +//! - `GET` - get the value associated with the provided key, if present +//! - `PUT` - set the value associated with the provided key +//! - `DELETE` - remove a key from the cache +//! +//! In all cases the key is passed in as the request path in the request +//! and the value is passed in as the request body. The protocol supports +//! reusing the HTTP connection for multiple requests. The only length +//! specification supported by pelikan is setting the Content-Length header. + +#[macro_use] +extern crate thiserror; + +mod error; +pub mod request; +pub mod response; +mod util; + +pub use crate::error::Error; +pub use crate::request::Headers; +pub use crate::request::{ParseData, Request, RequestData, RequestParser}; +pub use crate::response::Response; + +pub type Result = std::result::Result; +pub type ParseResult = Result; + +pub trait Storage { + fn get(&mut self, key: &[u8], headers: &Headers) -> Response; + fn put(&mut self, key: &[u8], value: &[u8], headers: &Headers) -> Response; + fn delete(&mut self, key: &[u8], headers: &Headers) -> Response; +} diff --git a/src/protocol/http/src/request.rs b/src/protocol/http/src/request.rs new file mode 100644 index 000000000..b2d33df5f --- /dev/null +++ b/src/protocol/http/src/request.rs @@ -0,0 +1,207 @@ +// Copyright 2022 Twitter, Inc. +// Licensed under the Apache License, Version 2.0 +// http://www.apache.org/licenses/LICENSE-2.0 + +use std::fmt; +use std::mem::MaybeUninit; + +use crate::{response::status_line, Error, ParseResult}; +use httparse::{Header, ParserConfig, Status}; +use logger::{error, klog}; +use protocol_common::{Parse, ParseOk}; + +#[derive(Clone)] +pub struct Headers(Vec<(String, Vec)>); + +pub struct ParseData(pub Result); + +impl Headers { + fn from_httparse(headers: &[Header]) -> Self { + Self( + headers + .iter() + .map(|hdr| (hdr.name.to_owned(), hdr.value.to_owned())) + .collect(), + ) + } + + pub fn header(&self, hdr: &str) -> Option<&[u8]> { + self.0 + .iter() + .find(|(name, _)| name.eq_ignore_ascii_case(hdr)) + .map(|(_, value)| &**value) + } +} + +#[derive(Clone, Debug)] +pub struct Request { + pub data: RequestData, + pub headers: Headers, +} + +impl Request { + pub fn data(&self) -> &RequestData { + &self.data + } + + pub fn header(&self, hdr: &str) -> Option<&[u8]> { + self.headers.header(hdr) + } +} + +#[derive(Clone)] +pub enum RequestData { + Get(Vec), + Put(Vec, Vec), + Delete(Vec), +} + +#[derive(Clone, Default)] +pub struct RequestParser { + config: ParserConfig, +} + +impl RequestParser { + pub fn new() -> Self { + Self::default() + } + + pub fn do_parse(&self, buf: &mut &[u8]) -> ParseResult { + let mut headers = [MaybeUninit::uninit(); 32]; + let mut request = httparse::Request::new(&mut []); + let status = + self.config + .parse_request_with_uninit_headers(&mut request, *buf, &mut headers)?; + + let count = match status { + Status::Complete(count) => count, + Status::Partial => return Err(Error::PartialRequest(None)), + }; + + *buf = &buf[count..]; + + let method = request.method.ok_or(Error::InternalError( + "request was complete but had no method", + ))?; + let key = request + .path + .ok_or(Error::InternalError("request was complete but had no path"))?; + + let key = urlencoding::decode_binary(&key.as_bytes()).into_owned(); + let headers = Headers::from_httparse(request.headers); + + match method { + "GET" => Ok(Request { + data: RequestData::Get(key), + headers, + }), + "DELETE" => Ok(Request { + data: RequestData::Delete(key), + headers, + }), + "PUT" => { + let content_length = headers + .header("Content-Length") + .ok_or(Error::BadContentLength)?; + let len: usize = std::str::from_utf8(content_length) + .map_err(|_| Error::BadContentLength)? + .parse() + .map_err(|_| Error::BadContentLength)?; + + if buf.len() < len { + return Err(Error::PartialRequest(Some(len - buf.len()))); + } + + let (value, newbuf) = buf.split_at(len); + *buf = newbuf; + + Ok(Request { + data: RequestData::Put(key, value.to_owned()), + headers, + }) + } + _ => return Err(Error::BadRequestMethod), + } + } +} + +impl Parse for RequestParser { + fn parse(&self, buffer: &[u8]) -> Result, std::io::Error> { + let mut buf = buffer; + let result = self.do_parse(&mut buf); + + let consumed = match result.is_ok() { + true => unsafe { buf.as_ptr().offset_from(buffer.as_ptr()) as usize }, + false => 0, + }; + + if matches!(result, Err(Error::PartialRequest(_))) { + return Err(std::io::Error::from(std::io::ErrorKind::WouldBlock)); + } + + Ok(ParseOk::new(ParseData(result), consumed)) + } +} + +impl logger::Klog for Request { + type Response = crate::Response; + + fn klog(&self, response: &Self::Response) { + use bstr::BStr; + + let status = response.status(); + let line = status_line(status).unwrap_or(""); + + match self.data() { + RequestData::Get(key) => klog!("GET '{}' => {} {}", BStr::new(key), status, line), + RequestData::Delete(key) => klog!("DELETE '{}' => {} {}", BStr::new(key), status, line), + RequestData::Put(key, val) => { + klog!( + "PUT '{}' {} => {} {}", + BStr::new(key), + val.len(), + status, + line + ) + } + }; + } +} + +impl logger::Klog for ParseData { + type Response = crate::Response; + + fn klog(&self, response: &Self::Response) { + if let Ok(request) = &self.0 { + request.klog(response); + } + } +} + +impl fmt::Debug for RequestData { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + use bstr::BStr; + + match self { + Self::Get(key) => f.debug_tuple("Get").field(&BStr::new(key)).finish(), + Self::Put(key, value) => f + .debug_tuple("Put") + .field(&BStr::new(key)) + .field(&BStr::new(value)) + .finish(), + Self::Delete(key) => f.debug_tuple("Delete").field(&BStr::new(key)).finish(), + } + } +} + +impl fmt::Debug for Headers { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> fmt::Result { + let mut list = f.debug_list(); + + for (name, value) in self.0.iter() { + list.entry(&(name.as_str(), bstr::BStr::new(value))); + } + + list.finish() + } +} diff --git a/src/protocol/http/src/response.rs b/src/protocol/http/src/response.rs new file mode 100644 index 000000000..101938c85 --- /dev/null +++ b/src/protocol/http/src/response.rs @@ -0,0 +1,208 @@ +// Copyright 2022 Twitter, Inc. +// Licensed under the Apache License, Version 2.0 +// http://www.apache.org/licenses/LICENSE-2.0 + +use arrayvec::ArrayVec; +use phf::{phf_map, Map}; +use protocol_common::{BufMut, Compose}; +use std::io::Write; + +pub struct Response { + builder: ResponseBuilder, + body: Option>, +} + +impl Response { + pub fn builder(status: u16) -> ResponseBuilder { + ResponseBuilder::new(status) + } + + pub fn status(&self) -> u16 { + self.builder.status + } +} + +pub struct ResponseBuilder { + headers: Vec, + status: u16, + close: bool, +} + +impl ResponseBuilder { + pub fn new(status: u16) -> Self { + let mut data = Vec::with_capacity(2048); + write!( + &mut data, + "HTTP/1.1 {} {}\r\n", + status, + status_line(status).unwrap_or("") + ) + .unwrap(); + + Self { + headers: data, + close: false, + status, + } + } + + pub fn header(&mut self, key: &str, value: &[u8]) -> &mut Self { + assert!(!self.headers.is_empty()); + + self.headers.extend_from_slice(key.as_bytes()); + self.headers.extend_from_slice(b": "); + self.headers.extend_from_slice(value); + self.headers.extend_from_slice(b"\r\n"); + + self + } + + pub fn should_close(&mut self, close: bool) -> &mut Self { + self.close = close; + self + } + + /// Build a response with no body + pub fn empty(&mut self) -> Response { + assert!(!self.headers.is_empty()); + + Response { + builder: self.take(), + body: None, + } + } + + /// Build a response with the specified body, also appends a Content-Length + /// header. + pub fn body(&mut self, body: &[u8]) -> Response { + assert!(!self.headers.is_empty()); + + let body = body.to_owned(); + Response { + builder: self.take(), + body: Some(body), + } + } + + fn take(&mut self) -> Self { + Self { + headers: std::mem::take(&mut self.headers), + close: self.close, + status: self.status, + } + } +} + +impl Compose for Response { + fn compose(&self, dst: &mut dyn BufMut) -> usize { + let mut dst = crate::util::CountingBuf::new(dst); + + dst.put_slice(&self.builder.headers); + + if self.builder.close { + dst.put_slice(b"Connection: close\r\n"); + } else { + dst.put_slice(b"Connection: keep-alive\r\n"); + dst.put_slice(b"Keep-Alive: timeout=60\r\n"); + } + + if let Some(body) = &self.body { + let mut lenbuf = ArrayVec::::new(); + write!(&mut lenbuf, "{}", body.len()).unwrap(); + + dst.put_slice(b"Content-Length: "); + dst.put_slice(&lenbuf); + dst.put_slice(b"\r\n"); + } + + dst.put_slice(b"\r\n"); + + if let Some(body) = &self.body { + dst.put_slice(body); + } + + dst.count() + } + + fn should_hangup(&self) -> bool { + self.builder.close + } +} + +pub(crate) fn status_line(status: u16) -> Option<&'static str> { + STATUSES.get(&status).copied() +} + +const STATUSES: Map = phf_map! { + // Informational Responses + 100u16 => "Continue", + 101u16 => "Switching Protocols", + 102u16 => "Processing", + 103u16 => "Early Hints", + + // Successful Responses + 200u16 => "OK", + 201u16 => "Created", + 202u16 => "Accepted", + 203u16 => "Non-Authoritative Information", + 204u16 => "No Content", + 205u16 => "Reset Content", + 206u16 => "Partial Content", + 207u16 => "Multi-Status", + 208u16 => "Already Reported", + 226u16 => "IM Used", + + // Redirect Responses + 300u16 => "Multiple Choices", + 301u16 => "Moved Permanently", + 302u16 => "Found", + 303u16 => "See Other", + 304u16 => "Not Modified", + 305u16 => "Use Proxy", + 307u16 => "Temporary Redirect", + 308u16 => "Permanent Redirect", + + // Client Error Responses + 400u16 => "Bad Request", + 401u16 => "Unauthorized", + 402u16 => "Payment Required", + 403u16 => "Forbidden", + 404u16 => "Not Found", + 405u16 => "Method Not Allowed", + 406u16 => "Not Acceptable", + 407u16 => "Proxy Authentication Required", + 408u16 => "Request Timeout", + 409u16 => "Conflict", + 410u16 => "Gone", + 411u16 => "Length Required", + 412u16 => "Precondition Failed", + 413u16 => "Payload Too Large", + 414u16 => "URI Too Long", + 415u16 => "Unsupported Media Type", + 416u16 => "Range Not Satisfiable", + 417u16 => "Expectation Mailed", + 418u16 => "I'm a Teapot", + 421u16 => "Misdirected Request", + 422u16 => "Unprocessable Entity", + 423u16 => "Locked", + 424u16 => "Failed Dependency", + 425u16 => "Too Early", + 426u16 => "Upgrade Required", + 428u16 => "Precondition Required", + 429u16 => "Too Many Requests", + 431u16 => "Request Header Field Too Large", + 451u16 => "Unavailable For Legal Reasons", + + // Server Error Responses + 500u16 => "Internal Server Error", + 501u16 => "Not Implemented", + 502u16 => "Bad Gateway", + 503u16 => "Service Unavailable", + 504u16 => "Gateway Timeout", + 505u16 => "HTTP Version Not Supported", + 506u16 => "Variant Also Negotiates", + 507u16 => "Insufficient Storage", + 508u16 => "Loop Detected", + 510u16 => "Not Extended", + 511u16 => "Network Authentication Required", +}; diff --git a/src/protocol/http/src/util.rs b/src/protocol/http/src/util.rs new file mode 100644 index 000000000..23f39205b --- /dev/null +++ b/src/protocol/http/src/util.rs @@ -0,0 +1,39 @@ +// Copyright 2022 Twitter, Inc. +// Licensed under the Apache License, Version 2.0 +// http://www.apache.org/licenses/LICENSE-2.0 + +use bytes::buf::UninitSlice; +use protocol_common::BufMut; + +pub(crate) struct CountingBuf { + buf: B, + count: usize, +} + +impl CountingBuf { + pub fn new(buf: B) -> Self { + Self { buf, count: 0 } + } + + pub fn count(&self) -> usize { + self.count + } +} + +unsafe impl BufMut for CountingBuf +where + B: BufMut, +{ + fn remaining_mut(&self) -> usize { + self.buf.remaining_mut() + } + + unsafe fn advance_mut(&mut self, cnt: usize) { + self.count += cnt; + self.buf.advance_mut(cnt) + } + + fn chunk_mut(&mut self) -> &mut UninitSlice { + self.buf.chunk_mut() + } +} diff --git a/src/protocol/http/tests/request.rs b/src/protocol/http/tests/request.rs new file mode 100644 index 000000000..926d53f0a --- /dev/null +++ b/src/protocol/http/tests/request.rs @@ -0,0 +1,108 @@ +// Copyright 2022 Twitter, Inc. +// Licensed under the Apache License, Version 2.0 +// http://www.apache.org/licenses/LICENSE-2.0 + +use assert_matches::assert_matches; +use protocol_http::{Error as ParseError, Request, RequestData, RequestParser}; + +fn parse_to_end(data: &[u8]) -> protocol_http::Result { + let mut buffer = data; + + let parser = RequestParser::new(); + let request = parser.do_parse(&mut buffer)?; + + assert_eq!(buffer.len(), 0); + assert!( + buffer.as_ptr() == data.as_ptr().wrapping_add(data.len()), + "buffer did not point to end of data slice" + ); + + Ok(request) +} + +#[test] +fn parse_get() { + let data: &[u8] = b"\ + GET /test HTTP/1.1\r\n\ + Server: no\r\n\ + \r\n\ + "; + + let request = parse_to_end(data).expect("failed to parse request"); + let key = assert_matches!(request.data(), RequestData::Get(key) => key); + + assert_eq!(key, b"/test"); +} + +#[test] +fn parse_put() { + let data: &[u8] = b"\ + PUT /test HTTP/1.1\r\n\ + Content-Length: 10\r\n\ + Test: blah\r\n\ + \r\n\ + abcdefghij\ + "; + + let request = parse_to_end(data).expect("failed to parse request"); + let (key, value) = + assert_matches!(request.data(), RequestData::Put(key, value) => (key, value)); + + assert_eq!(key, b"/test"); + assert_eq!(value, b"abcdefghij"); +} + +#[test] +fn parse_delete() { + let data: &[u8] = b"\ + DELETE /test HTTP/1.1\r\n\ + Test: blah\r\n\ + \r\n\ + "; + + let request = parse_to_end(data).expect("failed to parse request"); + let key = assert_matches!(request.data(), RequestData::Delete(key) => key); + + assert_eq!(key, b"/test"); +} + +#[test] +fn parse_header_case_insensitive() { + let data: &[u8] = b"\ + GET /blah HTTP/1.1\r\n\ + Test: yes\r\n\ + \r\n\ + "; + + let request = parse_to_end(data).expect("failed to parse request"); + + assert_eq!(request.header("test"), Some("yes".as_bytes())); + assert_eq!(request.header("Test"), Some("yes".as_bytes())); + assert_eq!(request.header("tEsT"), Some("yes".as_bytes())); +} + +#[test] +fn parse_key_urlencoded() { + let parser = RequestParser::new(); + let mut data: &[u8] = b"\ + GET /%21%40%23%24%25%5E%26%2A%28%29 HTTP/1.1\r\n\ + \r\n\ + "; + + let request = parser.do_parse(&mut data).expect("failed to parse request"); + let key = assert_matches!(request.data(), RequestData::Get(key) => key); + + assert_eq!(key, b"/!@#$%^&*()"); +} + +#[test] +fn parse_incomplete() { + let data: &[u8] = b"\ + PUT /aaaaaa HTTP/1.1\r\n\ + Content-Length: 100\r\n\ + \r\n\ + "; + let result = parse_to_end(data); + + assert_matches!(result, Err(ParseError::PartialRequest(Some(100)))); +} diff --git a/src/protocol/http/tests/response.rs b/src/protocol/http/tests/response.rs new file mode 100644 index 000000000..4586c4d27 --- /dev/null +++ b/src/protocol/http/tests/response.rs @@ -0,0 +1,57 @@ +// Copyright 2022 Twitter, Inc. +// Licensed under the Apache License, Version 2.0 +// http://www.apache.org/licenses/LICENSE-2.0 + +use bstr::BStr; +use protocol_common::Compose; +use protocol_http::Response; + +#[test] +fn response_with_body() { + let body = b"TEST BODY"; + let response = Response::builder(200) + .header("Test", b"test") + .should_close(true) + .body(body); + + let mut data = Vec::new(); + response.compose(&mut data); + + assert_eq!( + BStr::new(&data), + BStr::new( + b"\ + HTTP/1.1 200 OK\r\n\ + Test: test\r\n\ + Connection: close\r\n\ + Content-Length: 9\r\n\ + \r\n\ + TEST BODY\ + " + ) + ); +} + +#[test] +fn response_empty() { + let response = Response::builder(418) + .header("Test", b"test") + .should_close(false) + .empty(); + + let mut data = Vec::new(); + response.compose(&mut data); + + assert_eq!( + BStr::new(&data), + BStr::new( + b"\ + HTTP/1.1 418 I'm a Teapot\r\n\ + Test: test\r\n\ + Connection: keep-alive\r\n\ + Keep-Alive: timeout=60\r\n\ + \r\n\ + " + ) + ); +}