From 2789db9ca8a7a3d1e951ebbb0310c66df99d0bbc Mon Sep 17 00:00:00 2001 From: Charles Sibbald Date: Thu, 9 Jul 2026 22:45:02 +0300 Subject: [PATCH] feat(client): native HTTP/1.1 client replacing may_http Add feature-gated may_minihttp::client with HttpClient, Request, and Response API-compatible with may_http::client. DELETE/PUT/PATCH write_head is safe (no Drop panic). Enables BRRTRouter to drop the abandoned may_http crate without waiting on upstream. --- Cargo.toml | 3 + docs/Epics/01-core-client/README.md | 47 +++++ .../stories/01.1-project-setup.md | 70 ++++++ .../stories/01.2-error-types.md | 106 ++++++++++ .../stories/01.3-http-client.md | 116 ++++++++++ .../stories/01.4-request-builder.md | 119 +++++++++++ .../01-core-client/stories/01.5-response.md | 167 +++++++++++++++ .../stories/01.6-integration-tests.md | 163 ++++++++++++++ docs/design-http-client.md | 127 +++++++++++ src/client/body/body_reader.rs | 178 ++++++++++++++++ src/client/body/body_writer.rs | 99 +++++++++ src/client/body/mod.rs | 5 + src/client/buffer.rs | 180 ++++++++++++++++ src/client/client_impl.rs | 99 +++++++++ src/client/mod.rs | 14 ++ src/client/request.rs | 199 ++++++++++++++++++ src/client/response.rs | 115 ++++++++++ src/lib.rs | 3 + 18 files changed, 1810 insertions(+) create mode 100644 docs/Epics/01-core-client/README.md create mode 100644 docs/Epics/01-core-client/stories/01.1-project-setup.md create mode 100644 docs/Epics/01-core-client/stories/01.2-error-types.md create mode 100644 docs/Epics/01-core-client/stories/01.3-http-client.md create mode 100644 docs/Epics/01-core-client/stories/01.4-request-builder.md create mode 100644 docs/Epics/01-core-client/stories/01.5-response.md create mode 100644 docs/Epics/01-core-client/stories/01.6-integration-tests.md create mode 100644 docs/design-http-client.md create mode 100644 src/client/body/body_reader.rs create mode 100644 src/client/body/body_writer.rs create mode 100644 src/client/body/mod.rs create mode 100644 src/client/buffer.rs create mode 100644 src/client/client_impl.rs create mode 100644 src/client/mod.rs create mode 100644 src/client/request.rs create mode 100644 src/client/response.rs diff --git a/Cargo.toml b/Cargo.toml index 5e3686f..11b823d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,6 +21,7 @@ httparse = "1" once_cell = "1" may = { version = "0.3.46", default-features = false } +http = { version = "0.2", optional = true } [dev-dependencies] atoi = "2" @@ -46,6 +47,8 @@ may_postgres = { git = "https://github.com/Xudong-Huang/may_postgres.git", defau [features] default = ["may/default"] +# Native HTTP/1.1 client (drop-in for may_http::client). Opt-in for backwards compatibility. +client = ["http"] [profile.release] opt-level = 3 diff --git a/docs/Epics/01-core-client/README.md b/docs/Epics/01-core-client/README.md new file mode 100644 index 0000000..f6eae73 --- /dev/null +++ b/docs/Epics/01-core-client/README.md @@ -0,0 +1,47 @@ +# Epic 01: Core Client + +## Overview + +Native HTTP/1.1 client for `may_minihttp`: connection management, request/response types, and +`may_http::client`-compatible API. No dependency on the abandoned `may_http` crate. + +**Status:** IN PROGRESS (Phase 1 — compat layer landed 2026-07-09) + +**Target Milestone:** Release 0.2.0 (Microscaler fork) + +**Dependencies:** None + +--- + +## Stories + +| Story | Title | Status | +|-------|-------|--------| +| [01.1](stories/01.1-project-setup.md) | Project Setup & Cargo.toml | DONE | +| [01.2](stories/01.2-error-types.md) | Error Types | DEFERRED (Phase 2 — uses `io::Result` in Phase 1) | +| [01.3](stories/01.3-http-client.md) | HttpClient: Connection & Configuration | DONE | +| [01.4](stories/01.4-request-builder.md) | RequestBuilder: GET/POST with Headers | DEFERRED (Phase 2) | +| [01.5](stories/01.5-response.md) | Response: Status, Headers, Body, JSON | DONE (compat `Response`) | +| [01.6](stories/01.6-integration-tests.md) | Integration Tests with Mock Server | TODO | + +--- + +## Definition of Done (Epic-level) + +- [x] Native client module under `src/client/`, feature-gated +- [x] `cargo check --features client` passes +- [x] DELETE/PUT/PATCH do not panic in `Request::Drop` +- [x] BRRTRouter migrated off `may_http` +- [ ] Integration test against in-process `HttpServer` +- [ ] `docs/design-http-client.md` reflects shipped architecture +- [ ] Server-side code unchanged without `client` feature + +--- + +## Risks + +| Risk | Mitigation | +|------|-----------| +| Breaking server API | Client in `src/client/`, feature-gated | +| `http 0.2` vs `1.0` conflict | `http 0.2` only in `client/` module | +| Upstream may_minihttp drift | Microscaler fork branch `integration/microscaler-fork` | diff --git a/docs/Epics/01-core-client/stories/01.1-project-setup.md b/docs/Epics/01-core-client/stories/01.1-project-setup.md new file mode 100644 index 0000000..4fb817b --- /dev/null +++ b/docs/Epics/01-core-client/stories/01.1-project-setup.md @@ -0,0 +1,70 @@ +# Story 01.1: Project Setup & Cargo.toml + +## Description + +Set up the `client` module structure in `may_minihttp`. Update `Cargo.toml` with the `http = "0.2"` dependency (aliased as `http_legacy`), add the `client` feature gate, create the module skeleton, and write a smoke test proving the module compiles and links. + +## Functional Requirements + +- [ ] `may_minihttp/src/client/mod.rs` exists and re-exports the client module +- [ ] `Cargo.toml` declares `http = "0.2"` as a dependency (use alias `http_legacy` to avoid conflict with workspace `http 1.0`) +- [ ] `Cargo.toml` declares `serde` and `serde_json` as optional dependencies (behind `client` feature) +- [ ] `Cargo.toml` declares the `client` feature flag that gates all client-related code +- [ ] `lib.rs` conditionally includes `mod client` only when `client` feature is enabled +- [ ] Server-side exports (`HttpServer`, `Request`, `Response`, `BodyReader`) are unchanged and compile without the `client` feature +- [ ] `just test` (or `cargo test`) passes with zero errors when the crate compiles + +## Non-Functional Requirements + +- [ ] No breaking changes to existing public API +- [ ] The `client` feature must be **optional** — default features do not include it (backwards compatible) +- [ ] The `http = "0.2"` dependency must be an alias (`package = "http"`) to avoid conflict with workspace `http 1.0` +- [ ] `Cargo.lock` is committed (or generated deterministically) +- [ ] No `unsafe` code in the setup + +## Acceptance Criteria + +1. `cargo check --features client` succeeds with zero errors and zero warnings +2. `cargo check` (without `--features client`) succeeds — server-only mode is unchanged +3. `cargo test --features client` runs the smoke test and passes +4. `cargo doc --features client` builds without errors +5. `rustfmt --check` passes on all new files + +## Definition of Done + +- [ ] `git status` shows no untracked `.rs` files outside `src/client/` +- [ ] `rustc --edition=2021` compiles with `-D warnings` +- [ ] `clippy` with `-D warnings` passes +- [ ] Feature-gate verified: removing `--features client` still compiles all existing code +- [ ] README.md in `may_minihttp/` updated with client feature flag mention +- [ ] Commit message follows Conventional Commits: `feat(client): add client module scaffolding` + +## Tests + +### Unit Tests +- [ ] `client/mod.rs` compiles when feature is enabled +- [ ] `client/mod.rs` does not compile when feature is disabled (compile-fail test) + +### Smoke Test +```rust +#[cfg(feature = "client")] +#[test] +fn smoke_client_compiles() { + use may_minihttp::client; + // Just verify the module is accessible + let _: fn() = || { + let _ = std::any::type_name::(); + }; +} +``` + +## Dependencies + +- Blocks: None (this is the first story) +- Blocked by: None + +## Notes + +- Use `http_legacy` alias pattern already established in the workspace: `http_legacy = { package = "http", version = "0.2" }` +- `serde` and `serde_json` should be optional deps gated behind the `client` feature — server-only consumers don't need JSON +- The `client` feature should be opt-in (not in `default`) for backwards compatibility diff --git a/docs/Epics/01-core-client/stories/01.2-error-types.md b/docs/Epics/01-core-client/stories/01.2-error-types.md new file mode 100644 index 0000000..9db4786 --- /dev/null +++ b/docs/Epics/01-core-client/stories/01.2-error-types.md @@ -0,0 +1,106 @@ +# Story 01.2: Error Types + +## Description + +Define the `Error` enum for the HTTP client with all error variants needed for Phase 1: connection failures, timeouts, HTTP errors, JSON serialization/deserialization errors, and raw I/O errors. Implement `Display`, `Error`, and `From` trait conversions. + +## Functional Requirements + +- [ ] `may_minihttp/src/client/errors.rs` contains the `Error` enum with all variants +- [ ] `Error` enum variants: + - `ConnectionFailed { host: String, port: u16, cause: String }` + - `Timeout` + - `HttpError { status: StatusCode, reason: String, body: String }` + - `Serialization(serde_json::Error)` + - `Deserialization(serde_json::Error)` + - `Io(io::Error)` +- [ ] `impl Display for Error` — human-readable messages for all variants +- [ ] `impl std::error::Error for Error` +- [ ] `impl From for Error` — converts I/O errors to `Error::Io` +- [ ] `impl From for Error` — converts to `Serialization` or `Deserialization` variant +- [ ] `Error` is `pub` and re-exported from `client/mod.rs` +- [ ] `HttpError` variant includes status code as `http::StatusCode` (from `http 0.2`) + +## Non-Functional Requirements + +- [ ] No `unwrap()` or `panic!()` in error construction +- [ ] Error messages are actionable (include host, port, status code when available) +- [ ] `Error` does not derive `Clone` or `PartialEq` (errors are not comparable) +- [ ] `Error` uses `http 0.2` types (not `http 1.0`) +- [ ] Documentation comments on each variant explaining when it is returned + +## Acceptance Criteria + +1. `Error` variant matches the design spec exactly +2. All `From` conversions compile and work correctly +3. `Display` output is meaningful for debugging and logging +4. Error construction does not panic in any code path +5. `Error` can be used with `?` operator via `From` and `From` + +## Definition of Done + +- [ ] `cargo check --features client` passes with zero warnings +- [ ] `cargo clippy --features client -D warnings` passes +- [ ] Unit tests cover each error variant +- [ ] `Display` implementation tested for all variants +- [ ] `From` conversions tested (io::Error -> Error, serde_json::Error -> Error) +- [ ] Commit message: `feat(client): add error types with From conversions` + +## Tests + +### Unit Tests +```rust +#[test] +fn test_connection_failed_display() { + let err = Error::ConnectionFailed { + host: "localhost".into(), + port: 8080, + cause: "connection refused".into(), + }; + assert!(format!("{}", err).contains("localhost")); + assert!(format!("{}", err).contains("8080")); +} + +#[test] +fn test_http_error_display() { + let err = Error::HttpError { + status: StatusCode::NOT_FOUND, + reason: "Not Found".into(), + body: "404".into(), + }; + assert!(format!("{}", err).contains("404")); +} + +#[test] +fn test_from_io_error() { + let io_err = io::Error::new(io::ErrorKind::ConnectionRefused, "refused"); + let err: Error = io_err.into(); + match err { + Error::Io(_) => {} + _ => panic!("expected Io variant"), + } +} + +#[test] +fn test_from_serde_error() { + let json_err = serde_json::from_str::<()>("not json").unwrap_err(); + let err: Error = json_err.into(); + match err { + Error::Deserialization(_) => {} + _ => panic!("expected Deserialization variant"), + } +} +``` + +### Compile-Fail Test +- Verify `Error` does not implement `Clone` or `PartialEq` (compile-fail if it does) + +## Dependencies + +- Depends on: Story 01.1 (Project Setup) +- This story is a prerequisite for all other stories (every module needs Error types) + +## Notes + +- `serde_json::Error` does not implement `Clone` or `PartialEq` — the wrapper variant carries it, so `Error` should not derive these traits either +- Consider adding a `source()` method to `Error` that returns the underlying error (via `std::error::Error::source()`) for error chain debugging diff --git a/docs/Epics/01-core-client/stories/01.3-http-client.md b/docs/Epics/01-core-client/stories/01.3-http-client.md new file mode 100644 index 0000000..01071a5 --- /dev/null +++ b/docs/Epics/01-core-client/stories/01.3-http-client.md @@ -0,0 +1,116 @@ +# Story 01.3: HttpClient — Connection & Configuration + +## Description + +Implement the `HttpClient` struct that manages a single TCP connection to a remote server. Supports connection from host:port or base URL, configurable timeout, default headers, and connection reuse. This is the core stateful client object that all requests flow through. + +## Functional Requirements + +- [ ] `may_minihttp/src/client/client.rs` contains the `HttpClient` struct +- [ ] `HttpClient` struct fields: + - `conn: Rc>>` — shared connection (mirrors wrk-rs pattern) + - `base_url: Uri` — base URL for resolving relative paths + - `timeout: Option` — default timeout for all requests + - `default_headers: HeaderMap` — headers applied to every request +- [ ] `HttpClient::connect(host: &str, port: u16) -> Result` — raw TCP connect +- [ ] `HttpClient::from_url(url: &str) -> Result` — parse URL, extract host/port +- [ ] `HttpClient::with_timeout(timeout: Duration) -> Self` — builder method for timeout +- [ ] `HttpClient::with_default_headers(headers: HeaderMap) -> Self` — builder method for headers +- [ ] `HttpClient::get(path: &str) -> RequestBuilder` — start building a GET request +- [ ] `HttpClient::post(path: &str) -> RequestBuilder` — start building a POST request +- [ ] Connection reuse: multiple `.get()`/`.post()` calls on the same `HttpClient` reuse the same TCP connection +- [ ] `HttpClient` implements `Clone` via `Rc::clone(conn)` — cheap to share across coroutines +- [ ] `base_url` is optional; if not set, paths are treated as absolute (no base prefix) + +## Non-Functional Requirements + +- [ ] No blocking syscalls in hot paths — all I/O uses `may::net::TcpStream` +- [ ] `Rc>` pattern (not `Arc>`) — single-threaded within a coroutine +- [ ] `HttpClient` is `Send + Sync` (required for sharing across coroutine contexts) +- [ ] URL parsing handles: + - `http://host:port/path` + - `http://host` (port defaults to 80) + - `https://host:port/path` (reject — TLS not supported, return `Error::Io`) + - `host:port` (no scheme, default to http) +- [ ] `connect()` does not panic — returns `Error::ConnectionFailed` on failure +- [ ] `from_url()` does not panic — returns `Error::Io` on parse failure + +## Acceptance Criteria + +1. `HttpClient::connect("127.0.0.1", 8080)` establishes a TCP connection +2. `HttpClient::from_url("http://example.com:8080")` parses host/port correctly +3. `HttpClient::from_url("https://example.com")` returns `Error::Io` (TLS not supported) +4. Default headers are applied to every request built via `get()`/`post()` +5. Timeout is applied to the underlying `TcpStream` before each request +6. Multiple `.get()` calls on the same client reuse the same connection (verified via connection count) +7. `Clone` is cheap (just `Rc::clone`) — no deep copy of the connection + +## Definition of Done + +- [ ] `cargo check --features client -D warnings` passes +- [ ] `cargo clippy --features client -D warnings` passes +- [ ] Unit tests: connect, from_url, timeout, default_headers +- [ ] Integration test: send GET request to local server and receive response +- [ ] `HttpClient` is `Debug` (derive or impl) +- [ ] All public methods have `///` documentation comments +- [ ] No `unwrap()` or `expect()` in public API +- [ ] Commit message: `feat(client): add HttpClient with connection and configuration` + +## Tests + +### Unit Tests +```rust +#[test] +fn test_connect() { + let client = HttpClient::connect("127.0.0.1", 8080).expect("connect should succeed"); + assert!(!client.base_url.path().is_empty() || client.base_url.path().starts_with("/")); +} + +#[test] +fn test_from_url() { + let client = HttpClient::from_url("http://example.com:8080").expect("url parse should succeed"); + assert_eq!(client.base_url.host(), Some("example.com")); +} + +#[test] +fn test_from_url_no_port() { + let client = HttpClient::from_url("http://example.com").expect("url parse should succeed"); + assert_eq!(client.base_url.port_u16(), Some(80)); +} + +#[test] +fn test_from_url_https_rejected() { + let result = HttpClient::from_url("https://example.com"); + assert!(result.is_err()); +} + +#[test] +fn test_with_timeout() { + let client = HttpClient::connect("127.0.0.1", 8080) + .unwrap() + .with_timeout(Duration::from_secs(30)); + // Verify timeout is set — test via internal assertion +} + +#[test] +fn test_clone_is_cheap() { + let client = HttpClient::connect("127.0.0.1", 8080).unwrap(); + let cloned = client.clone(); + // Rc::ptr_eq should be true if clone shares the connection +} +``` + +### Integration Tests +- [ ] Connect to a may_minihttp server, send GET request, verify response body +- [ ] Send multiple GET requests on the same client, verify single connection used + +## Dependencies + +- Depends on: Story 01.1 (Project Setup), Story 01.2 (Error Types) +- This story is a prerequisite for Stories 01.4 (RequestBuilder) and 01.5 (Response) + +## Notes + +- The underlying connection type is wrapped in our own `HttpClient` struct for the rich API +- The `Rc>>` pattern is borrowed from wrk-rs — it allows sharing the connection across multiple requests within the same coroutine +- `TcpStream::set_read_timeout()` and `TcpStream::set_write_timeout()` are called before each request via the inner `conn` diff --git a/docs/Epics/01-core-client/stories/01.4-request-builder.md b/docs/Epics/01-core-client/stories/01.4-request-builder.md new file mode 100644 index 0000000..d6bec2d --- /dev/null +++ b/docs/Epics/01-core-client/stories/01.4-request-builder.md @@ -0,0 +1,119 @@ +# Story 01.4: RequestBuilder — GET/POST with Headers + +## Description + +Implement the `RequestBuilder` struct with fluent API for constructing HTTP requests. Supports GET and POST methods, custom headers, JSON body serialization, and raw byte body. The builder pattern allows chaining method calls before calling `.send()`. + +## Functional Requirements + +- [ ] `RequestBuilder` struct with fields: + - `method: Method` (from `http 0.2`) + - `path: String` + - `headers: HeaderMap` + - `body: Option>` + - `content_length: Option` +- [ ] `RequestBuilder::header(key: &str, value: &str) -> Self` — add a custom header +- [ ] `RequestBuilder::json_body(body: &T) -> Self` — serialize to JSON and set Content-Type +- [ ] `RequestBuilder::raw_body(body: &[u8], content_type: Option<&str>) -> Self` — set raw byte body +- [ ] `RequestBuilder::send(self, client: &HttpClient) -> Result` — execute the request +- [ ] GET requests have no body (ignore any body set via `raw_body()` or `json_body()`) +- [ ] POST requests include the body (Content-Length header set automatically) +- [ ] Default headers from `HttpClient` are merged into request headers (request-specific headers take precedence) +- [ ] `RequestBuilder::send()` constructs the raw HTTP request, writes to the shared connection, and returns the response +- [ ] `send()` uses `may_http::client::Request` internally for header/body setting + +## Non-Functional Requirements + +- [ ] No allocations in the hot path (builder pattern is consumed by `send()`, not cloned) +- [ ] JSON body is serialized synchronously (blocking) +- [ ] `RequestBuilder` is `Send` (required for passing between coroutines) +- [ ] `send()` does not panic — returns `Error` on any failure +- [ ] Header names are validated (ASCII only per HTTP spec) +- [ ] Content-Type defaults to `application/octet-stream` when not specified + +## Acceptance Criteria + +1. `client.get("/path").send()` returns a response without body +2. `client.post("/path").header("X-Custom", "value").send()` includes custom header +3. `client.post("/path").json_body(&obj).send()` serializes object to JSON with Content-Type header +4. `client.post("/path").raw_body(b"data", Some("text/plain")).send()` sends raw bytes +5. Multiple `.header()` calls accumulate headers +6. Request-specific headers override default headers from `HttpClient` +7. `send()` returns `Error::HttpError` for non-2xx status codes +8. GET requests ignore body (no Content-Length header sent) + +## Definition of Done + +- [ ] `cargo check --features client -D warnings` passes +- [ ] `cargo clippy --features client -D warnings` passes +- [ ] Unit tests: header, json_body, raw_body, send, override +- [ ] Integration tests: POST with JSON body to may_minihttp server +- [ ] All public methods have `///` documentation comments +- [ ] No `unwrap()` or `expect()` in public API +- [ ] Commit message: `feat(client): add RequestBuilder with GET/POST, headers, and JSON body` + +## Tests + +### Unit Tests +```rust +#[test] +fn test_get_no_body() { + // GET should not include Content-Length or body + let builder = HttpClient::connect("127.0.0.1", 8080) + .unwrap() + .get("/path") + .json_body(&serde_json::json!({"key": "value"})); + // Verify body is not set (or ignored) for GET +} + +#[test] +fn test_header_chain() { + let builder = HttpClient::connect("127.0.0.1", 8080) + .unwrap() + .post("/path") + .header("X-A", "a") + .header("X-B", "b") + .header("X-C", "c"); + // Verify all three headers are set +} + +#[test] +fn test_json_body_serializes() { + let obj = serde_json::json!({"key": "value"}); + let builder = HttpClient::connect("127.0.0.1", 8080) + .unwrap() + .post("/path") + .json_body(&obj); + // Verify body contains JSON and Content-Type is set +} + +#[test] +fn test_default_headers_overridden() { + let client = HttpClient::connect("127.0.0.1", 8080) + .unwrap() + .with_default_headers({ + let mut h = HeaderMap::new(); + h.insert("X-Custom", HeaderValue::from_static("default")); + h + }); + let builder = client.post("/path") + .header("X-Custom", "override"); + // Verify request-specific header overrides default +} +``` + +### Integration Tests +- [ ] POST with JSON body to may_minihttp server, verify server receives correct JSON +- [ ] GET with custom headers, verify server receives all headers +- [ ] Large JSON body (10KB) — verify no truncation + +## Dependencies + +- Depends on: Story 01.3 (HttpClient) +- This story is a prerequisite for Story 01.6 (Integration Tests) + +## Notes + +- The `may_http::client::Request` type is the low-level bridge — we set method, URI, headers, and body on it, then `drop(req)` to send +- `may_http::client::Request` implements `Drop` which writes the request head and flushes the body — this is how the request is actually sent +- JSON serialization is synchronous (`serde_json::to_vec`) diff --git a/docs/Epics/01-core-client/stories/01.5-response.md b/docs/Epics/01-core-client/stories/01.5-response.md new file mode 100644 index 0000000..3ad89ad --- /dev/null +++ b/docs/Epics/01-core-client/stories/01.5-response.md @@ -0,0 +1,167 @@ +# Story 01.5: Response — Status, Headers, Body, JSON + +## Description + +Implement the `Response` struct for reading and inspecting HTTP responses. Supports status code inspection, header access, body reading, JSON deserialization, and convenience methods like `is_success()` and `ensure_success()`. + +## Functional Requirements + +- [ ] `may_minihttp/src/client/response.rs` contains the `Response` struct +- [ ] `Response` struct fields: + - `status: StatusCode` (from `http 0.2`) + - `headers: HeaderMap` + - `body: Vec` — buffered response body +- [ ] `Response::status(&self) -> StatusCode` — return the HTTP status code +- [ ] `Response::is_success(&self) -> bool` — true for 2xx status codes +- [ ] `Response::is_client_error(&self) -> bool` — true for 4xx status codes +- [ ] `Response::is_server_error(&self) -> bool` — true for 5xx status codes +- [ ] `Response::header(&self, name: &str) -> Option<&str>` — get a header value by name +- [ ] `Response::body(&self) -> &[u8]` — get the response body as bytes +- [ ] `Response::json(&self) -> Result` — deserialize body as JSON +- [ ] `Response::ensure_success(&self) -> Result<(), Error>` — returns `Error::HttpError` for non-2xx +- [ ] All response bodies are buffered into `Vec` (no streaming in Phase 1) +- [ ] `Response` is `Debug` (derive or impl) + +## Non-Functional Requirements + +- [ ] `Response::json()` returns `Error::Deserialization` on invalid JSON +- [ ] `Response::ensure_success()` includes status code and body in the error message +- [ ] `Response::header()` is case-insensitive per HTTP spec +- [ ] Body buffering has a reasonable maximum (e.g., 1MB) to prevent memory exhaustion +- [ ] `Response` does not implement `Clone` (body is consumed on first read) — or does implement it if body is cloned on first access +- [ ] No panics in any `Response` method + +## Acceptance Criteria + +1. `response.status()` returns the correct HTTP status code +2. `response.is_success()` is true for 2xx, false otherwise +3. `response.is_client_error()` is true for 4xx +4. `response.is_server_error()` is true for 5xx +5. `response.header("Content-Type")` returns the value or None +6. `response.body()` returns the raw response bytes +7. `response.json::()` deserializes valid JSON correctly +8. `response.json::()` returns `Error::Deserialization` for invalid JSON +9. `response.ensure_success()` returns `Ok(())` for 2xx +10. `response.ensure_success()` returns `Error::HttpError` for non-2xx with status and body + +## Definition of Done + +- [ ] `cargo check --features client -D warnings` passes +- [ ] `cargo clippy --features client -D warnings` passes +- [ ] Unit tests: status, is_success, is_client_error, is_server_error, header, body, json, ensure_success +- [ ] Integration test: receive response from may_minihttp server and inspect it +- [ ] All public methods have `///` documentation comments +- [ ] No `unwrap()` or `expect()` in public API +- [ ] Body size limit tested (reject bodies > 1MB) +- [ ] Commit message: `feat(client): add Response with status, headers, body, and JSON deserialization` + +## Tests + +### Unit Tests +```rust +#[test] +fn test_status_codes() { + // Test with constructed Response + let response = Response { + status: StatusCode::OK, + headers: HeaderMap::new(), + body: b"{}".to_vec(), + }; + assert!(response.is_success()); + assert!(!response.is_client_error()); + assert!(!response.is_server_error()); +} + +#[test] +fn test_status_4xx() { + let response = Response { + status: StatusCode::NOT_FOUND, + headers: HeaderMap::new(), + body: b"Not Found".to_vec(), + }; + assert!(!response.is_success()); + assert!(response.is_client_error()); +} + +#[test] +fn test_status_5xx() { + let response = Response { + status: StatusCode::INTERNAL_SERVER_ERROR, + headers: HeaderMap::new(), + body: b"Server Error".to_vec(), + }; + assert!(response.is_server_error()); +} + +#[test] +fn test_header_case_insensitive() { + let mut headers = HeaderMap::new(); + headers.insert("Content-Type", HeaderValue::from_static("application/json")); + // Test case-insensitive lookup +} + +#[test] +fn test_json_deserialization() { + let response = Response { + status: StatusCode::OK, + headers: HeaderMap::new(), + body: b"{\"key\": \"value\"}".to_vec(), + }; + let result: serde_json::Map = response.json().unwrap(); + assert_eq!(result.get("key").unwrap().as_str().unwrap(), "value"); +} + +#[test] +fn test_json_deserialization_error() { + let response = Response { + status: StatusCode::OK, + headers: HeaderMap::new(), + body: b"not json".to_vec(), + }; + let result: Result = response.json(); + assert!(result.is_err()); +} + +#[test] +fn test_ensure_success_ok() { + let response = Response { + status: StatusCode::OK, + headers: HeaderMap::new(), + body: b"OK".to_vec(), + }; + assert!(response.ensure_success().is_ok()); +} + +#[test] +fn test_ensure_success_error() { + let response = Response { + status: StatusCode::INTERNAL_SERVER_ERROR, + headers: HeaderMap::new(), + body: b"500".to_vec(), + }; + let err = response.ensure_success().unwrap_err(); + match err { + Error::HttpError { status, body, .. } => { + assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR); + assert_eq!(body, "500"); + } + _ => panic!("expected HttpError variant"), + } +} +``` + +### Integration Tests +- [ ] Receive JSON response from may_minihttp server, deserialize correctly +- [ ] Receive text response, verify body() returns raw bytes +- [ ] Receive error response (4xx/5xx), verify error handling + +## Dependencies + +- Depends on: Story 01.2 (Error Types) +- This story is a prerequisite for Story 01.6 (Integration Tests) + +## Notes + +- Body buffering is configurable — the default should be reasonable for typical API responses +- `Response::header()` should be case-insensitive — HTTP headers are case-insensitive per RFC 7230 +- Consider: should `Response` implement `Clone`? Body is already in `Vec`, so cloning is cheap. This enables multiple reads of the same response. diff --git a/docs/Epics/01-core-client/stories/01.6-integration-tests.md b/docs/Epics/01-core-client/stories/01.6-integration-tests.md new file mode 100644 index 0000000..babba95 --- /dev/null +++ b/docs/Epics/01-core-client/stories/01.6-integration-tests.md @@ -0,0 +1,163 @@ +# Story 01.6: Integration Tests with Mock Server + +## Description + +Write comprehensive integration tests using a mock may_minihttp server. Tests verify the full request/response lifecycle: connection, header passing, JSON serialization/deserialization, status code handling, error cases, and connection reuse. Use may_minihttp's existing test infrastructure (in-memory handler or local TCP server). + +## Functional Requirements + +- [ ] `may_minihttp/tests/client_integration.rs` (or `tests/client/` directory) contains integration tests +- [ ] Tests use a real may_minihttp server (TCP) or in-memory test server if available +- [ ] Test categories: + - [ ] GET request with no body + - [ ] POST request with JSON body + - [ ] Custom headers (single and multiple) + - [ ] Status code handling (200, 404, 500) + - [ ] JSON serialization round-trip + - [ ] Connection reuse (multiple requests on same client) + - [ ] Default headers applied to all requests + - [ ] Request-specific headers override defaults + - [ ] Invalid JSON in response triggers deserialization error + - [ ] Connection failure returns `Error::ConnectionFailed` + - [ ] HTTP error status returns `Error::HttpError` + - [ ] TLS URL rejection (https:// returns error) + +## Non-Functional Requirements + +- [ ] Tests run in < 10 seconds total +- [ ] Tests are deterministic (no randomness, no network dependency beyond local) +- [ ] Tests use `#[test]` functions, no async (may coroutines are sync in this context) +- [ ] Each test is independent — no shared mutable state between tests +- [ ] Tests use `may_minihttp::HttpServer` to create a mock server + +## Acceptance Criteria + +1. All 12 test scenarios pass +2. Tests cover happy path and error paths +3. Tests verify error types are correct (not just that an error occurred) +4. Tests verify connection reuse (count connections, not request count) +5. Tests verify header case-insensitivity +6. Tests verify body size limit enforcement + +## Definition of Done + +- [ ] `cargo test --features client` passes with all integration tests +- [ ] Test coverage >= 80% for client module +- [ ] Tests run in < 10 seconds +- [ ] Tests are deterministic and reproducible +- [ ] Test infrastructure is documented in README +- [ ] Commit message: `feat(client): add integration tests with mock server` + +## Tests + +```rust +// Test fixture: mock server handler +struct TestHandler; + +impl HttpService for TestHandler { + fn call(&mut self, req: Request, res: &mut Response) { + match (req.method(), req.path()) { + ("GET", "/ok") => { + res.status_code(200, "OK"); + res.body("Hello, World!"); + } + ("POST", "/json") => { + let body = req.body(); + let mut json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + json["echo"] = serde_json::Value::String("received".into()); + res.status_code(200, "OK"); + res.body_vec(serde_json::to_vec(&json).unwrap()); + } + ("GET", "/headers") => { + // Echo back the custom headers + let header_count = req.headers().len(); + let mut json = serde_json::Map::new(); + for h in req.headers() { + json.insert(h.name.to_string(), serde_json::Value::String(h.value.to_string())); + } + res.status_code(200, "OK"); + res.body_vec(serde_json::to_vec(&json).unwrap()); + } + ("GET", "/not-found") => { + res.status_code(404, "Not Found"); + res.body("404 Not Found"); + } + ("GET", "/server-error") => { + res.status_code(500, "Internal Server Error"); + res.body("500 Server Error"); + } + _ => { + res.status_code(404, "Not Found"); + res.body("Not Found"); + } + } + } +} + +// Test: GET request with no body +#[test] +fn test_get_ok() { + let server = HttpServer(TestHandler).start("127.0.0.1:0").unwrap(); + let client = HttpClient::connect("127.0.0.1", /* actual port */).unwrap(); + let response = client.get("/ok").send().unwrap(); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.body(), b"Hello, World!"); + server.stop(); +} + +// Test: POST with JSON body +#[test] +fn test_post_json() { + let server = HttpServer(TestHandler).start("127.0.0.1:0").unwrap(); + let client = HttpClient::connect("127.0.0.1", /* actual port */).unwrap(); + let obj = serde_json::json!({"key": "value"}); + let response = client.post("/json").json_body(&obj).send().unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let result: serde_json::Value = response.json().unwrap(); + assert_eq!(result["echo"].as_str().unwrap(), "received"); + server.stop(); +} + +// Test: Custom headers +#[test] +fn test_custom_headers() { + let server = HttpServer(TestHandler).start("127.0.0.1:0").unwrap(); + let client = HttpClient::connect("127.0.0.1", /* actual port */).unwrap(); + let response = client.get("/headers") + .header("X-Custom-1", "value1") + .header("X-Custom-2", "value2") + .send() + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let headers: serde_json::Value = response.json().unwrap(); + assert!(headers["X-Custom-1"].is_string()); + server.stop(); +} + +// Test: Connection reuse +#[test] +fn test_connection_reuse() { + let server = HttpServer(TestHandler).start("127.0.0.1:0").unwrap(); + let client = HttpClient::connect("127.0.0.1", /* actual port */).unwrap(); + + // Send 10 GET requests — should all reuse the same connection + for _ in 0..10 { + let response = client.get("/ok").send().unwrap(); + assert_eq!(response.status(), StatusCode::OK); + } + // Verify only one connection was used (implementation-specific check) + server.stop(); +} +``` + +## Dependencies + +- Depends on: All stories in Epic 01 (01.1 through 01.5) +- This is the final story in Epic 01 + +## Notes + +- may_minihttp already has test infrastructure (`tests/test_server.rs`, `tests/simple_header_test.rs`). Reuse these patterns where possible. +- The mock server should run on a random port to avoid conflicts. may_minihttp's `HttpServer::start` returns the server handle — we need to discover the bound port. +- Integration tests require the `client` feature to be enabled (`--features client`). +- Consider: should integration tests use a real may_minihttp server (TCP) or an in-memory mock? TCP is more realistic but slower. In-memory is faster but doesn't test the actual network path. diff --git a/docs/design-http-client.md b/docs/design-http-client.md new file mode 100644 index 0000000..7ab54b1 --- /dev/null +++ b/docs/design-http-client.md @@ -0,0 +1,127 @@ +# may_minihttp Client — Design Document + +## Status (2026-07-09) + +**Phase 1 shipped in-tree:** native HTTP/1.1 client under `may_minihttp::client`, feature-gated +(`client`). Drop-in compatible with `may_http::client::{HttpClient, Request, Response}` — BRRTRouter +migrates here; the abandoned `may_http` crate is retired. + +Future phases (RequestBuilder, JSON helpers, middleware, pooling) remain on the roadmap below. + +--- + +## Why a client in may_minihttp? + +`may_http` (~6 years unmaintained) is a thin wrapper around `may::net::TcpStream`. `may_minihttp` is +actively maintained (same author: Xudong Huang) and already owns HTTP parsing for the server side. + +Adding a **native** client here: + +1. One crate for may HTTP server + client (Microscaler fork already in BRRTRouter / Sesame-IDAM) +2. Fixes like DELETE/PUT/PATCH `write_head` live in the maintained crate, not a dead fork +3. Server types (`request.rs`, `response.rs`) stay unchanged — client is a separate module +4. Feature-gated (`client`) so server-only builds are unchanged + +## Current may_minihttp API + +### Server (existing, unchanged) + +```rust +HttpServer::start(addr) -> JoinHandle<()> +Request::method() / path() / headers() / body() +Response::status_code() / header() / body() +``` + +### Client (Phase 1 — `feature = "client"`) + +```rust +use may_minihttp::client::{HttpClient, Request, Response}; + +let mut client = HttpClient::connect(("127.0.0.1", 8080))?; +client.set_timeout(Some(Duration::from_secs(30))); + +// Shortcuts +let rsp = client.get("/health".parse()?)?; +let rsp = client.post("/api".parse()?, body_bytes)?; + +// Full control (DELETE, PUT, PATCH, custom headers) +let mut req = client.new_request(Method::DELETE, "/fleet/42".parse()?); +// req.headers_mut().insert(...); +drop(req); // writes head on Drop +let rsp = client.send_request(req)?; // or get_rsp after drop(req) +``` + +API mirrors `may_http::client` so BRRTRouter `proxy.rs` / `fetch.rs` need only import path changes. + +## Architecture + +``` +may_minihttp/src/ +├── lib.rs +├── http_server.rs # existing server +├── request.rs # existing server request (httparse) +├── response.rs # existing server response +└── client/ # feature = "client" + ├── mod.rs + ├── buffer.rs # BufferIo + ├── body/ # BodyReader, BodyWriter + ├── client_impl.rs # HttpClient + ├── request.rs # outgoing Request (http 0.2) + └── response.rs # incoming Response (httparse decode) +``` + +**No `may_http` dependency.** Transport is `may::net::TcpStream` + `BufferIo`, same wire format as +the former `may_http` client. + +### DELETE / PUT / PATCH + +`Request::write_head` handles all methods. Non-GET/HEAD/POST with no `Content-Length` assume an empty +body (no `Transfer-Encoding: chunked` for those methods). Call `set_content_length` before writing +when the body size is known. + +Regression tests in `client/request.rs`. + +## Dependencies + +| Crate | Scope | Notes | +|-------|-------|-------| +| `http = "0.2"` | `client` feature only | Uri, Method, StatusCode, HeaderMap | +| `httparse`, `bytes`, `log`, `may` | always | shared with server | + +Server continues to use `httparse` directly — no type mixing between server and client modules. + +## Evolution Plan + +### Phase 1: Core client (this PR) ✅ + +- Native `HttpClient`, `Request`, `Response` — `may_http`-compatible API +- GET, POST, DELETE, PUT, PATCH via `new_request` / `send_request` +- Feature gate `client` +- BRRTRouter migration off `may_http` + +### Phase 2: Rich API (future) + +- `RequestBuilder`, domain `Error` enum, JSON helpers (`serde_json` optional) +- PUT/DELETE/PATCH convenience methods on `HttpClient` +- Middleware trait + +### Phase 3: Robustness (future) + +- Connection pooling, retries, streaming bodies for large responses + +### Phase 4: Production (future) + +- TLS behind feature flag (BRRTRouter already uses rustls for HTTPS) +- Metrics, compression, HTTP/2 if `may` supports it + +## Resolved Questions + +1. **Separate crate vs in-tree?** → In `may_minihttp`, feature-gated. +2. **Replace `may_http`?** → Yes for Microscaler consumers (BRRTRouter). Fork archived after migration. +3. **`http` version?** → `0.2` in client module only; workspace `http 1.0` for BRRTRouter server types unchanged. +4. **Body buffering?** → Buffer via `Read` on `Response` (same as `may_http`). + +## Open Questions + +1. Upstream contribution to Xudong-Huang/may_minihttp — proceed locally; upstream ping optional. +2. Rich client API (Phase 2) vs minimal compat layer — compat first, richness when Sesame-IDAM needs it. diff --git a/src/client/body/body_reader.rs b/src/client/body/body_reader.rs new file mode 100644 index 0000000..7915bcf --- /dev/null +++ b/src/client/body/body_reader.rs @@ -0,0 +1,178 @@ +use std::cell::RefCell; +use std::fmt; +use std::io::{self, Read}; +use std::rc::Rc; + +use super::BodyReader::*; + +pub enum BodyReader { + SizedReader(Rc>, usize), + ChunkReader(Rc>, Option), + EmptyReader, +} + +impl fmt::Debug for BodyReader { + fn fmt(&self, f: &mut fmt::Formatter) -> Result<(), fmt::Error> { + let name = match *self { + SizedReader(..) => "SizedReader", + ChunkReader(..) => "ChunkReader", + EmptyReader => "EmptyReader", + }; + write!(f, "BodyReader {}", name) + } +} + +impl Read for BodyReader { + #[inline] + fn read(&mut self, buf: &mut [u8]) -> io::Result { + use std::cmp; + match *self { + SizedReader(ref r, ref mut remain) => { + let len = cmp::min(*remain, buf.len()); + if len == 0 { + return Ok(0); + } + let mut r = r.borrow_mut(); + let n = r.read(&mut buf[0..len])?; + *remain -= n; + Ok(n) + } + ChunkReader(ref r, ref mut opt_remaining) => { + let mut r = r.borrow_mut(); + let mut rem = match *opt_remaining { + Some(ref rem) => *rem, + // None means we don't know the size of the next chunk + None => read_chunk_size(&mut *r)?, + }; + trace!("Chunked read, remaining={:?}", rem); + + if rem == 0 { + if opt_remaining.is_none() { + eat(&mut *r, b"\r\n")?; + } + + *opt_remaining = Some(0); + + trace!("end of chunked"); + + return Ok(0); + } + + let to_read = cmp::min(rem, buf.len()); + let count = r.read(&mut buf[..to_read])?; + + if count == 0 { + *opt_remaining = Some(0); + return Err(io::Error::new(io::ErrorKind::Other, "early eof")); + } + + rem -= count; + *opt_remaining = if rem > 0 { + Some(rem) + } else { + eat(&mut *r, b"\r\n")?; + None + }; + Ok(count) + } + EmptyReader => Ok(0), + } + } +} + +impl Drop for BodyReader { + fn drop(&mut self) { + // consume all the chunks + let mut buf = vec![0; 4096]; + loop { + match self.read(&mut buf) { + Err(e) => { + error!("drop Reader err={}", e); + break; + } + Ok(n) => { + if n == 0 { + break; + } + } + } + } + } +} + +fn eat(rdr: &mut dyn Read, bytes: &[u8]) -> io::Result<()> { + let mut buf = [0]; + for &b in bytes.iter() { + match rdr.read(&mut buf)? { + 1 if buf[0] == b => {} + _ => { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "Invalid characters found", + )); + } + } + } + Ok(()) +} + +/// Chunked chunks start with 1*HEXDIGIT, indicating the size of the chunk. +fn read_chunk_size(rdr: &mut dyn Read) -> io::Result { + macro_rules! byte ( + ($rdr:ident) => ({ + let mut buf = [0]; + match $rdr.read(&mut buf)? { + 1 => buf[0], + _ => return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "Invalid chunk size line, read byte", + )), + } + }) + ); + let mut size = 0; + let mut in_ext = false; + let mut in_chunk_size = true; + loop { + match byte!(rdr) { + b @ b'0'..=b'9' if in_chunk_size => { + size <<= 4; + size += (b - b'0') as usize; + } + b @ b'a'..=b'f' if in_chunk_size => { + size <<= 4; + size += (b + 10 - b'a') as usize; + } + b @ b'A'..=b'F' if in_chunk_size => { + size <<= 4; + size += (b + 10 - b'A') as usize; + } + b'\r' => match byte!(rdr) { + b'\n' => break, + _ => { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "Invalid chunk size line, read new line", + )); + } + }, + b';' if !in_ext => { + in_ext = true; + in_chunk_size = false; + } + b'\t' | b' ' if !in_ext & !in_chunk_size => {} + b'\t' | b' ' if in_chunk_size => in_chunk_size = false, + ext if in_ext => { + error!("chunk extension byte={}", ext); + } + _ => { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "Invalid chunk size line, unkonw byte", + )); + } + } + } + trace!("chunk size={:?}", size); + Ok(size) +} diff --git a/src/client/body/body_writer.rs b/src/client/body/body_writer.rs new file mode 100644 index 0000000..52fb4ae --- /dev/null +++ b/src/client/body/body_writer.rs @@ -0,0 +1,99 @@ +use std::cell::RefCell; +use std::fmt; +use std::io::{self, Write}; +use std::rc::Rc; + +use super::BodyWriter::*; + +pub enum BodyWriter { + SizedWriter(Rc>, usize), + ChunkWriter(Rc>), + // this is used to write all the data out when get drop + EmptyWriter(Rc>), + // this is used as a invalid place holder + InvalidWriter, +} + +impl fmt::Debug for BodyWriter { + fn fmt(&self, f: &mut fmt::Formatter) -> Result<(), fmt::Error> { + let name = match *self { + SizedWriter(..) => "SizedWriter", + ChunkWriter(_) => "ChunkWriter", + EmptyWriter(_) => "EmptyWriter", + InvalidWriter => "Invalid", + }; + write!(f, "BodyWriter {}", name) + } +} + +impl Write for BodyWriter { + #[inline] + fn write(&mut self, buf: &[u8]) -> io::Result { + use std::cmp; + match *self { + SizedWriter(ref w, ref mut remain) => { + let len = cmp::min(*remain, buf.len()); + let mut w = w.borrow_mut(); + let n = w.write(&buf[0..len])?; + *remain -= n; + Ok(n) + } + ChunkWriter(ref w) => { + let chunk_size = buf.len(); + let mut w = w.borrow_mut(); + write!(w, "{:X}\r\n", chunk_size)?; + w.write_all(buf)?; + w.write_all(b"\r\n")?; + Ok(chunk_size) + } + EmptyWriter(_) => Ok(0), + InvalidWriter => unreachable!(), + } + } + + #[inline] + fn flush(&mut self) -> io::Result<()> { + match *self { + SizedWriter(ref w, _) => { + let mut w = w.borrow_mut(); + w.flush() + } + ChunkWriter(ref w) => { + let mut w = w.borrow_mut(); + w.flush() + } + EmptyWriter(ref w) => { + let mut w = w.borrow_mut(); + w.flush() + } + InvalidWriter => unreachable!(), + } + } +} + +impl Drop for BodyWriter { + fn drop(&mut self) { + match *self { + SizedWriter(ref w, remain) => { + let mut w = w.borrow_mut(); + if remain > 0 { + // write enough data when drop + let buf = vec![0u8; remain]; + w.write_all(&buf).ok(); + } + w.flush().ok(); + } + ChunkWriter(ref w) => { + // write the chunk end and flush + let mut w = w.borrow_mut(); + w.write_all(b"0\r\n\r\n").ok(); + w.flush().ok(); + } + EmptyWriter(ref w) => { + let mut w = w.borrow_mut(); + w.flush().ok(); + } + InvalidWriter => {} + } + } +} diff --git a/src/client/body/mod.rs b/src/client/body/mod.rs new file mode 100644 index 0000000..cddce6a --- /dev/null +++ b/src/client/body/mod.rs @@ -0,0 +1,5 @@ +mod body_reader; +mod body_writer; + +pub use body_reader::BodyReader; +pub use body_writer::BodyWriter; diff --git a/src/client/buffer.rs b/src/client/buffer.rs new file mode 100644 index 0000000..6dbc692 --- /dev/null +++ b/src/client/buffer.rs @@ -0,0 +1,180 @@ +use std::cmp; +use std::io::{self, BufRead, Read, Write}; + +use bytes::{Buf, BufMut, BytesMut}; + +#[derive(Debug)] +pub struct BufferIo { + inner: T, + reader_buf: BytesMut, + writer_buf: (Vec, usize), +} + +const INIT_BUFFER_SIZE: usize = 4096; + +impl BufferIo { + #[inline] + pub fn new(io: T) -> Self { + BufferIo::with_capacity(io, INIT_BUFFER_SIZE) + } + + #[inline] + pub fn with_capacity(io: T, cap: usize) -> Self { + BufferIo { + inner: io, + reader_buf: BytesMut::with_capacity(cap), + writer_buf: (vec![0u8; cap], 0), + } + } + + #[inline] + pub fn inner_mut(&mut self) -> &mut T { + &mut self.inner + } +} + +impl BufferIo { + /// read some data into internal buffer + #[inline] + pub fn bump_read(&mut self) -> io::Result { + if self.reader_buf.capacity() - self.reader_buf.len() < 32 { + self.reader_buf.reserve(INIT_BUFFER_SIZE); + } + + let spare = self.reader_buf.spare_capacity_mut(); + let buf = + unsafe { std::slice::from_raw_parts_mut(spare.as_mut_ptr() as *mut u8, spare.len()) }; + let n = self.inner.read(buf)?; + // SAFETY: `read` initialized exactly `n` bytes at the start of spare capacity. + unsafe { + self.reader_buf.advance_mut(n); + } + Ok(n) + } + + /// return the intneral buffer + #[inline] + pub fn get_reader_buf(&mut self) -> &mut BytesMut { + &mut self.reader_buf + } +} + +impl Read for BufferIo { + fn read(&mut self, buf: &mut [u8]) -> io::Result { + use std::ptr; + if self.reader_buf.is_empty() { + self.bump_read()?; + } + + let len = unsafe { + let src = self.reader_buf.as_ref(); + let len = cmp::min(buf.len(), src.len()); + ptr::copy_nonoverlapping(src.as_ptr(), buf.as_mut_ptr(), len); + len + }; + + self.reader_buf.advance(len); + Ok(len) + } +} + +impl Write for BufferIo { + #[inline] + fn write(&mut self, buf: &[u8]) -> io::Result { + use std::ptr; + let buf_len = self.writer_buf.0.len(); + if buf_len == self.writer_buf.1 { + self.flush()?; + } + + let remain = buf_len - self.writer_buf.1; + let len = cmp::min(remain, buf.len()); + let dst = self.writer_buf.0.as_mut_ptr(); + unsafe { + let dst = dst.add(self.writer_buf.1); + ptr::copy_nonoverlapping(buf.as_ptr(), dst, len); + } + self.writer_buf.1 += len; + Ok(len) + } + + #[inline] + fn flush(&mut self) -> io::Result<()> { + let buf = &self.writer_buf.0[0..self.writer_buf.1]; + self.inner.write_all(buf)?; + self.writer_buf.1 = 0; + Ok(()) + } +} + +impl BufRead for BufferIo { + fn fill_buf(&mut self) -> io::Result<&[u8]> { + self.bump_read()?; + Ok(self.reader_buf.chunk()) + } + + #[inline] + fn consume(&mut self, amt: usize) { + self.reader_buf.advance(amt) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::{self, BufRead, Read}; + + struct SlowRead(u8); + + impl Read for SlowRead { + fn read(&mut self, buf: &mut [u8]) -> io::Result { + let state = self.0; + self.0 += 1; + (&match state % 3 { + 0 => b"foo", + 1 => b"bar", + _ => b"baz", + }[..]) + .read(buf) + } + } + + #[test] + fn test_consume_and_get_buf() { + let mut rdr = BufferIo::new(SlowRead(0)); + rdr.bump_read().unwrap(); + rdr.consume(1); + assert_eq!(rdr.get_reader_buf().as_ref(), b"oo"); + rdr.bump_read().unwrap(); + rdr.bump_read().unwrap(); + assert_eq!(rdr.get_reader_buf().as_ref(), b"oobarbaz"); + rdr.consume(5); + assert_eq!(rdr.get_reader_buf().as_ref(), b"baz"); + rdr.consume(3); + assert_eq!(rdr.get_reader_buf().as_ref(), b""); + } + + #[test] + fn test_resize() { + let raw = vec![1u8; 100]; + let mut rdr = BufferIo::with_capacity(&raw[..], 65); + rdr.bump_read().unwrap(); + assert_eq!(rdr.get_reader_buf().len(), 65); + rdr.bump_read().unwrap(); + assert_eq!(rdr.get_reader_buf().len(), 100); + } + + #[test] + fn test_write() { + let data = vec![0u8; 100]; + let mut wrt = BufferIo::with_capacity(io::sink(), 40); + let n = wrt.write(&data).unwrap(); + assert_eq!(n, 40); + let n = wrt.write(&[0u8; 6]).unwrap(); + assert_eq!(n, 6); + let n = wrt.write(&data).unwrap(); + assert_eq!(n, 34); + let n = wrt.write(&data).unwrap(); + assert_eq!(n, 40); + } +} diff --git a/src/client/client_impl.rs b/src/client/client_impl.rs new file mode 100644 index 0000000..e32f1ae --- /dev/null +++ b/src/client/client_impl.rs @@ -0,0 +1,99 @@ +use std::cell::RefCell; +use std::io; +use std::net::ToSocketAddrs; +use std::rc::Rc; +use std::time::Duration; + +use bytes::Buf; +use http::{Method, Uri}; +use may::net::TcpStream; + +use crate::client::buffer::BufferIo; +use crate::client::{Request, Response}; + +/// Coroutine HTTP/1.1 client — API-compatible with `may_http::client::HttpClient`. +#[derive(Debug)] +pub struct HttpClient { + conn: Rc>>, +} + +impl HttpClient { + /// Connect to the given address. + pub fn connect(remote: A) -> io::Result { + let stream = TcpStream::connect(remote)?; + let stream = BufferIo::new(stream); + Ok(HttpClient { + conn: Rc::new(RefCell::new(stream)), + }) + } + + /// Set read/write timeout on the underlying connection. + pub fn set_timeout(&mut self, timeout: Option) -> &mut Self { + { + let mut s = self.conn.borrow_mut(); + let s = s.inner_mut(); + s.set_read_timeout(timeout).unwrap(); + s.set_write_timeout(timeout).unwrap(); + } + self + } + + /// GET shortcut — sends request on drop and reads the response. + pub fn get(&mut self, uri: Uri) -> io::Result { + let mut req = Request::new(self.conn.clone()); + *req.uri_mut() = uri; + drop(req); + self.get_rsp() + } + + /// POST shortcut with body bytes. + pub fn post(&mut self, uri: Uri, mut data: T) -> io::Result { + let mut req = Request::new(self.conn.clone()); + *req.method_mut() = Method::POST; + *req.uri_mut() = uri; + let body = data.copy_to_bytes(data.remaining()); + req.send(&body)?; + drop(req); + self.get_rsp() + } + + /// Build a request with the given method and URI. + #[inline] + pub fn new_request(&self, method: Method, uri: Uri) -> Request { + let mut req = Request::new(self.conn.clone()); + *req.method_mut() = method; + *req.uri_mut() = uri; + req + } + + /// Send a request built from this client and read the response. + #[inline] + pub fn send_request(&mut self, req: Request) -> io::Result { + use std::io::Write; + let conn: Rc> = self.conn.clone(); + assert_eq!(Rc::ptr_eq(&conn, req.conn()), true); + drop(req); + self.get_rsp() + } + + #[inline] + fn get_rsp(&mut self) -> io::Result { + let mut stream = self.conn.borrow_mut(); + loop { + match super::response::decode(stream.get_reader_buf())? { + None => { + if stream.bump_read()? == 0 { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "connection breaked", + )); + } + } + Some(mut rsp) => { + rsp.set_reader(self.conn.clone()); + return Ok(rsp); + } + } + } + } +} diff --git a/src/client/mod.rs b/src/client/mod.rs new file mode 100644 index 0000000..be7798d --- /dev/null +++ b/src/client/mod.rs @@ -0,0 +1,14 @@ +//! Coroutine HTTP/1.1 client (drop-in replacement for `may_http::client`). +//! +//! Enabled with the `client` feature. Uses native transport on `may::net::TcpStream` +//! — no dependency on the abandoned `may_http` crate. + +mod body; +mod buffer; +mod client_impl; +mod request; +mod response; + +pub use client_impl::HttpClient; +pub use request::Request; +pub use response::Response; diff --git a/src/client/request.rs b/src/client/request.rs new file mode 100644 index 0000000..d0a57b7 --- /dev/null +++ b/src/client/request.rs @@ -0,0 +1,199 @@ +//! Outgoing HTTP/1.1 requests (client side). +use std::cell::RefCell; +use std::fmt; +use std::io::{self, Write}; +use std::ops::{Deref, DerefMut}; +use std::rc::Rc; + +use crate::client::body::BodyWriter; +use http::{self, Method}; + +/// Outgoing request for [`super::HttpClient`]. +/// +/// Derefs to `http::Request`. On drop, writes the request head and +/// flushes the body unless the handler already did so. +pub struct Request { + raw_req: http::Request, + writer: Rc>, + body_size: Option, +} + +impl fmt::Debug for Request { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "", self.method()) + } +} + +impl Request { + /// Creates a new Request that can be used to write to a network stream. + #[inline] + pub fn new(stream: Rc>) -> Request { + Request { + raw_req: http::Request::new(BodyWriter::InvalidWriter), + writer: stream, + body_size: None, + } + } + + fn write_head_impl(&mut self) -> io::Result<()> { + let mut writer = self.writer.borrow_mut(); + + write!( + writer, + "{} {} {:?}\r\n", + self.method(), + self.uri(), + self.version() + )?; + write!(writer, "User-Agent: may_minihttp\r\nAccept: */*\r\n")?; + + for (key, value) in self.headers().iter() { + write!( + writer, + "{}: {}\r\n", + key.as_str(), + value.to_str().unwrap_or("") + )?; + } + + if let Some(len) = self.body_size { + write!(writer, "Content-Length: {}\r\n", len)? + } + + write!(writer, "\r\n")?; + Ok(()) + } + + fn write_head(&mut self) -> io::Result { + let body = match *self.method() { + Method::GET | Method::HEAD => BodyWriter::EmptyWriter(self.writer.clone()), + Method::POST => match self.body_size { + Some(size) => BodyWriter::SizedWriter(self.writer.clone(), size), + None => BodyWriter::ChunkWriter(self.writer.clone()), + }, + // DELETE / PUT / PATCH / OPTIONS etc. — sized body when Content-Length + // is set; otherwise assume no body (no Transfer-Encoding for these methods). + _ => match self.body_size { + Some(size) => BodyWriter::SizedWriter(self.writer.clone(), size), + None => BodyWriter::EmptyWriter(self.writer.clone()), + }, + }; + self.write_head_impl()?; + Ok(body) + } + + /// Writes the body and ends the Request. + #[inline] + pub fn send(&mut self, body: &[u8]) -> io::Result<()> { + self.body_size = Some(body.len()); + self.write_all(body) + } + + /// Set Content-Length before writing the request body (when not using [`Self::send`]). + #[inline] + pub fn set_content_length(&mut self, len: usize) { + self.body_size = Some(len); + } + + pub(super) fn conn(&self) -> &Rc> { + &self.writer + } +} + +impl Deref for Request { + type Target = http::Request; + + #[inline] + fn deref(&self) -> &Self::Target { + &self.raw_req + } +} + +impl DerefMut for Request { + #[inline] + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.raw_req + } +} + +impl Write for Request { + #[inline] + fn write(&mut self, msg: &[u8]) -> io::Result { + if let BodyWriter::InvalidWriter = *self.body() { + *self.body_mut() = self.write_head()?; + } + self.body_mut().write(msg) + } + + #[inline] + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } +} + +impl Drop for Request { + fn drop(&mut self) { + use std::thread; + + if thread::panicking() { + return; + } + + if let BodyWriter::InvalidWriter = *self.body() { + *self.body_mut() = self + .write_head() + .unwrap_or_else(|_| BodyWriter::EmptyWriter(self.writer.clone())); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn request_with_method(method: Method, stream: Rc>>) -> Request { + let mut req = Request::new(stream); + *req.method_mut() = method; + *req.uri_mut() = "/things/42".parse().unwrap(); + req + } + + fn written(stream: &Rc>>) -> String { + String::from_utf8(stream.borrow().clone()).unwrap() + } + + #[test] + fn delete_without_body_writes_head_on_drop() { + let stream: Rc>> = Rc::new(RefCell::new(Vec::new())); + let req = request_with_method(Method::DELETE, stream.clone()); + drop(req); + let head = written(&stream); + assert!(head.starts_with("DELETE /things/42"), "head was: {head}"); + assert!(!head.contains("Content-Length"), "head was: {head}"); + } + + #[test] + fn put_with_sized_body_writes_content_length() { + let stream: Rc>> = Rc::new(RefCell::new(Vec::new())); + let mut req = request_with_method(Method::PUT, stream.clone()); + req.send(b"{\"a\":1}").unwrap(); + drop(req); + let head = written(&stream); + assert!(head.starts_with("PUT /things/42"), "head was: {head}"); + assert!(head.contains("Content-Length: 7"), "head was: {head}"); + assert!(head.ends_with("{\"a\":1}"), "head was: {head}"); + } + + #[test] + fn patch_and_options_do_not_panic() { + for method in [Method::PATCH, Method::OPTIONS] { + let stream: Rc>> = Rc::new(RefCell::new(Vec::new())); + let req = request_with_method(method.clone(), stream.clone()); + drop(req); + assert!( + written(&stream).starts_with(method.as_str()), + "no head written for {method}" + ); + } + } +} diff --git a/src/client/response.rs b/src/client/response.rs new file mode 100644 index 0000000..bef76fd --- /dev/null +++ b/src/client/response.rs @@ -0,0 +1,115 @@ +use std::cell::RefCell; +use std::fmt; +use std::io::{self, Read}; +use std::ops::{Deref, DerefMut}; +use std::rc::Rc; + +use bytes::{Bytes, BytesMut}; +use http::header::*; +use http::{self, Version}; +use httparse; + +use crate::client::body::BodyReader; + +pub(crate) fn decode(buf: &mut BytesMut) -> io::Result> { + #[inline] + fn get_slice(buf: &Bytes, data: &[u8]) -> Bytes { + let begin = data.as_ptr() as usize - buf.as_ptr() as usize; + buf.slice(begin..begin + data.len()) + } + + let mut headers: [httparse::Header; 64] = + unsafe { std::mem::MaybeUninit::uninit().assume_init() }; + let mut r = httparse::Response::new(&mut headers); + let status = r.parse(buf).map_err(|e| { + let msg = format!("failed to parse http Response: {:?}", e); + io::Error::new(io::ErrorKind::Other, msg) + })?; + + let bytes = match status { + httparse::Status::Complete(amt) => { + #[allow(clippy::cast_ref_to_mut)] + let buf = unsafe { &mut *(buf as *const _ as *mut BytesMut) }; + buf.split_to(amt).freeze() + } + httparse::Status::Partial => return Ok(None), + }; + + let version = match r.version { + Some(v) => { + if v == 0 { + Version::HTTP_10 + } else { + Version::HTTP_11 + } + } + None => Version::HTTP_11, + }; + + let mut rsp_builder = http::Response::builder(); + rsp_builder = rsp_builder.status(r.code.unwrap()).version(version); + + for header in r.headers.iter() { + let value = + unsafe { HeaderValue::from_maybe_shared_unchecked(get_slice(&bytes, header.value)) }; + rsp_builder = rsp_builder.header(header.name, value); + } + + rsp_builder + .body(BodyReader::EmptyReader) + .map(|req| Some(Response(req))) + .map_err(|e| { + let msg = format!("failed to build http Response: {:?}", e); + io::Error::new(io::ErrorKind::Other, msg) + }) +} + +/// HTTP response from a client request. +pub struct Response(http::Response); + +impl Response { + pub(crate) fn set_reader(&mut self, reader: Rc>) { + use std::str; + + let size = self.headers().get(CONTENT_LENGTH).map(|v| { + let s = unsafe { str::from_utf8_unchecked(v.as_bytes()) }; + s.parse().expect("failed to parse content length") + }); + + let body_reader = match size { + Some(n) => BodyReader::SizedReader(reader, n), + None => BodyReader::ChunkReader(reader, None), + }; + + *self.body_mut() = body_reader; + } +} + +impl Deref for Response { + type Target = http::Response; + + #[inline] + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl DerefMut for Response { + #[inline] + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} + +impl Read for Response { + #[inline] + fn read(&mut self, msg: &mut [u8]) -> io::Result { + self.body_mut().read(msg) + } +} + +impl fmt::Debug for Response { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "", self.status(), self.version()) + } +} diff --git a/src/lib.rs b/src/lib.rs index 4e558ca..6f5c025 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,6 +6,9 @@ mod http_server; mod request; mod response; +#[cfg(feature = "client")] +pub mod client; + pub use http_server::{HttpServer, HttpServerWithHeaders, HttpService, HttpServiceFactory}; pub use request::{ decode_default, decode_large, decode_standard, decode_xlarge, BodyReader, MaxHeaders, Request,