Skip to content
Merged
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
3 changes: 3 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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
Expand Down
47 changes: 47 additions & 0 deletions docs/Epics/01-core-client/README.md
Original file line number Diff line number Diff line change
@@ -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` |
70 changes: 70 additions & 0 deletions docs/Epics/01-core-client/stories/01.1-project-setup.md
Original file line number Diff line number Diff line change
@@ -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::<client::Error>();
};
}
```

## 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
106 changes: 106 additions & 0 deletions docs/Epics/01-core-client/stories/01.2-error-types.md
Original file line number Diff line number Diff line change
@@ -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<io::Error> for Error` — converts I/O errors to `Error::Io`
- [ ] `impl From<serde_json::Error> 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<io::Error>` and `From<serde_json::Error>`

## 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
116 changes: 116 additions & 0 deletions docs/Epics/01-core-client/stories/01.3-http-client.md
Original file line number Diff line number Diff line change
@@ -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<RefCell<BufferIo<TcpStream>>>` — shared connection (mirrors wrk-rs pattern)
- `base_url: Uri` — base URL for resolving relative paths
- `timeout: Option<Duration>` — default timeout for all requests
- `default_headers: HeaderMap` — headers applied to every request
- [ ] `HttpClient::connect(host: &str, port: u16) -> Result<Self, Error>` — raw TCP connect
- [ ] `HttpClient::from_url(url: &str) -> Result<Self, Error>` — 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<RefCell<>>` pattern (not `Arc<Mutex<>>`) — 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<RefCell<BufferIo<TcpStream>>>` 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`
Loading
Loading