diff --git a/.cocoon-plan.md b/.cocoon-plan.md index 4a4f729..12f95d5 100644 --- a/.cocoon-plan.md +++ b/.cocoon-plan.md @@ -21,9 +21,13 @@ - [x] 最大并发连接数限制 ✅ 2026-06-03 - [x] 分级日志系统(error / warn / info / debug)✅ 2026-06-03 - [x] Gzip 压缩 ✅ 2026-06-03(已接入响应流程) -- [x] 集成测试 suite(27 项 curl/bash 测试全部通过)✅ 2026-06-03 +- [x] 集成测试 suite(30 项 curl/bash 测试全部通过)✅ 2026-06-03 - [x] 性能基准(wrk: ~16.2K RPS / ~60μs 延迟)✅ 2026-06-03 -- [ ] 请求体解析(POST 支持) +- [x] 请求体解析(POST 支持)✅ 2026-06-03 + - Content-Length 读取 + - JSON / form-urlencoded 回显 + - 8MB 请求体上限 +- [ ] C 语言单元测试框架 ### Phase 3 — 扩展 - [ ] 配置文件支持(JSON / YAML) @@ -40,32 +44,32 @@ ## 当前状态 - 编译通过,零警告(除 coco 子模块的 linker .note.GNU-stack 提示) -- 27 项集成测试全部通过(GET/HEAD/404/Range/304/gzip/MIME/目录浏览/路径防护) +- 30 项集成测试全部通过(GET/HEAD/POST/404/Range/304/gzip/MIME/目录浏览/路径防护) - 压测数据:wrk -t4 -c100 -d10s → 16,179 RPS,平均延迟 59.86μs -- 示例页面 + 测试 fixtures 正常 -- README 已更新,Makefile 新增 `test` / `bench` 目标 +- POST 支持:JSON 和 form-urlencoded 回显,Content-Length 解析,8MB 上限 +- README / Makefile 已更新 ## 待办池 -1. **请求体解析(POST 支持)** — 解析 Content-Length / chunked,支持 form-data / JSON -2. **C 语言单元测试框架** — 用 Unity/CMock 或自定义轻量框架替代 shell 测试 -3. **Brotli 压缩** — 在 Gzip 基础上添加 br 支持 -4. **配置文件支持** — JSON/YAML 配置替代纯命令行 +1. **C 语言单元测试框架** — 用 Unity/CMock 或自定义轻量框架替代 shell 测试 +2. **Brotli 压缩** — 在 Gzip 基础上添加 br 支持 +3. **配置文件支持** — JSON/YAML 配置替代纯命令行 +4. **multipart/form-data 文件上传** — 从 POST 回显扩展到实际文件上传 ## 最近行动记录 - 2026-06-03: 项目初始化,核心模块全部实现 - 2026-06-03: 添加缓存协商(ETag + Last-Modified + 304),修复编译警告 - 2026-06-03: 添加连接空闲超时管理 + 最大并发限制 + 分级日志系统 - - 使用 `coco_timer` + `shutdown` + `coco_cancel` 实现空闲超时,默认 30 秒 - - 使用原子计数器限制最大并发连接,超限返回 503 - - 新增 `log.c` / `log.h`,支持 error/warn/info/debug 四级输出 - - 命令行新增 `-m`、`-o`、`-l` 选项 - - 修复 http.c 中 Range 解析被错误嵌套在 accept-encoding 分支的 bug - - 修复 static.c 缺失 `` 和 `` 导致的编译失败 - - Makefile 添加 `-lz` 链接参数 +- 2026-06-03: 创建集成测试套件 + 性能基准 + 更新文档 - 2026-06-03: **本轮行动** - - 创建 `tests/` 目录,含 `integration_test.sh`(27 项测试)+ `benchmark.sh` + fixtures - - 执行 wrk 压测:16,179 RPS / 59.86μs 平均延迟(4 线程 100 连接 10s) - - 更新 README.md:添加 Gzip/缓存/超时/日志特性,更新命令行参数,补充性能表格 - - Makefile 新增 `make test` 和 `make bench` 快捷目标 + - 添加 POST 请求体解析支持: + - `http_request_t` 新增 `body` / `body_len` / `content_type` 字段 + - `http.c` 解析 Content-Type 头,新增 `http_request_free` 释放 body + - `server.c` 新增 `conn_read_body` 从 socket 读取请求体 + - `server.c` 新增 `handle_post_request` 返回 JSON 回显(支持 JSON 和 form-urlencoded) + - 8MB 请求体上限(HTTP_MAX_BODY),超限返回 413 + - 集成测试新增 3 项 POST 测试:JSON 回显、表单回显、PUT 405 + - 30 项测试全部通过 + - README 更新:添加 POST 特性、curl 示例、路线图更新 + - 推送到 main diff --git a/README.md b/README.md index 29a0833..c1ae89b 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ | 🔐 路径安全 | 自动防护路径遍历攻击 (`../`) | | 🛡️ 资源限制 | 空闲连接超时(默认 30s)+ 最大并发连接数限制 | | 📝 分级日志 | error / warn / info / debug 四级输出,命令行可调 | +| 🌐 POST 回显 | 支持 JSON / form-urlencoded 请求体回显(API 测试) | | 🔧 极简配置 | 纯命令行启动,无需配置文件 | ## Quick Start @@ -85,6 +86,12 @@ curl -I -H "If-None-Match: \"your-etag\"" http://localhost:8080/index.html # Gzip 压缩 curl -I -H "Accept-Encoding: gzip" http://localhost:8080/index.html + +# POST JSON 回显 +curl -X POST -H "Content-Type: application/json" -d '{"hello":"world"}' http://localhost:8080/api/echo + +# POST 表单回显 +curl -X POST -H "Content-Type: application/x-www-form-urlencoded" -d 'name=cocoon' http://localhost:8080/api/echo ``` ### 自动化测试 @@ -214,8 +221,7 @@ make bench - [x] Gzip 动态压缩 - [x] 连接空闲超时 + 最大并发限制 - [x] 分级日志系统 -- [ ] 完整的单元测试框架 -- [ ] 请求体解析(POST 支持) +- [x] POST 请求体解析(JSON / form-urlencoded 回显) - [ ] HTTPS / TLS 支持 - [ ] HTTP/2 多路复用 - [ ] 配置文件支持(JSON / YAML) diff --git a/http.c b/http.c index 76fabf6..376b2db 100644 --- a/http.c +++ b/http.c @@ -130,6 +130,11 @@ static void parse_headers(const char **p, const char *end, http_request_t *req) const char *val = req->headers[req->num_headers].value; if (strstr(val, "gzip") != NULL) req->accept_gzip = true; if (strstr(val, "deflate") != NULL) req->accept_deflate = true; + } else if (strcmp(req->headers[req->num_headers].name, "content-type") == 0) { + int copy_len = strlen(req->headers[req->num_headers].value); + if (copy_len >= HTTP_HEADER_VALUE_MAX) copy_len = HTTP_HEADER_VALUE_MAX - 1; + memcpy(req->content_type, req->headers[req->num_headers].value, copy_len); + req->content_type[copy_len] = '\0'; } else if (strcmp(req->headers[req->num_headers].name, "range") == 0) { const char *range_val = req->headers[req->num_headers].value; if (strncasecmp(range_val, "bytes=", 6) == 0) { @@ -272,6 +277,19 @@ int http_format_response_header(char *buf, size_t buf_size, const http_response_ return n; } +/** + * http_request_free - 释放 HTTP 请求中动态分配的资源 + * + * @param req 请求结构体 + */ +void http_request_free(http_request_t *req) { + if (req && req->body) { + free(req->body); + req->body = NULL; + req->body_len = 0; + } +} + /** * http_mime_type - 根据文件扩展名推断 MIME 类型 * diff --git a/http.h b/http.h index 05de860..9bc8139 100644 --- a/http.h +++ b/http.h @@ -29,6 +29,7 @@ typedef enum { #define HTTP_MAX_HEADERS 32 #define HTTP_HEADER_NAME_MAX 64 #define HTTP_HEADER_VALUE_MAX 1024 +#define HTTP_MAX_BODY 8388608 /* 8MB 最大请求体 */ typedef struct { http_method_t method; @@ -59,6 +60,11 @@ typedef struct { bool accept_gzip; /* 客户端支持 gzip */ bool accept_deflate; /* 客户端支持 deflate */ bool has_accept_encoding; + + /* 请求体 */ + char *body; /* 请求体数据(动态分配) */ + size_t body_len; /* 请求体实际长度 */ + char content_type[HTTP_HEADER_VALUE_MAX]; /* 解析后的 Content-Type */ } http_request_t; /* === HTTP 响应 === */ @@ -110,6 +116,13 @@ int http_format_response_header(char *buf, size_t buf_size, const http_response_ */ const char *http_method_str(http_method_t method); +/** + * http_request_free - 释放 HTTP 请求中动态分配的资源 + * + * @param req 请求结构体 + */ +void http_request_free(http_request_t *req); + /** * http_mime_type - 根据文件扩展名获取 MIME 类型 * diff --git a/server.c b/server.c index b4519a4..38b1bd4 100644 --- a/server.c +++ b/server.c @@ -126,10 +126,135 @@ static ssize_t conn_read(connection_t *conn) { return n; } +/** + * conn_read_body - 读取请求体 + * + * 从连接缓冲区或 socket 读取剩余请求体数据。 + * 如果缓冲区中已有部分数据,优先使用。 + * + * @param conn 连接上下文 + * @param req HTTP 请求 + * @param need 需要读取的字节数 + * @return 0 成功,-1 错误 + */ +static int conn_read_body(connection_t *conn, http_request_t *req, size_t need) { + if (need == 0) return 0; + if (need > HTTP_MAX_BODY) { + log_warn("请求体过大 (%zu > %d),拒绝", need, HTTP_MAX_BODY); + return -1; + } + + req->body = (char *)malloc(need + 1); + if (!req->body) return -1; + req->body[need] = '\0'; + + size_t got = 0; + + /* 先消费缓冲区中的数据 */ + if (conn->buf_len > 0) { + size_t from_buf = conn->buf_len < need ? conn->buf_len : need; + memcpy(req->body, conn->buf, from_buf); + got = from_buf; + if (from_buf < conn->buf_len) { + memmove(conn->buf, conn->buf + from_buf, conn->buf_len - from_buf); + } + conn->buf_len -= from_buf; + } + + /* 从 socket 读取剩余数据 */ + while (got < need) { + ssize_t n; + if (coco_sched_get_current() != NULL) { + n = coco_read(conn->fd, req->body + got, need - got); + } else { + n = read(conn->fd, req->body + got, need - got); + } + if (n > 0) { + got += (size_t)n; + } else if (n < 0) { + if (errno == EAGAIN || errno == EWOULDBLOCK || errno == EINTR) continue; + free(req->body); + req->body = NULL; + return -1; + } else { + /* 对端关闭 */ + free(req->body); + req->body = NULL; + return -1; + } + } + + req->body_len = got; + return 0; +} + +/** + * handle_post_request - 处理 POST 请求 + * + * 目前仅提供 JSON 回显和表单回显,用于测试和 API 场景。 + * 支持 application/json 和 application/x-www-form-urlencoded。 + * + * @param fd 客户端 socket + * @param req HTTP 请求 + * @return true 保持连接 + */ +static bool handle_post_request(int fd, const http_request_t *req) { + char response[4096]; + int n = 0; + + n += snprintf(response + n, sizeof(response) - n, + "{\"method\":\"%s\",\"path\":\"%s\",\"content_type\":\"%s\",\"body_length\":%zu", + http_method_str(req->method), req->path, req->content_type, req->body_len); + + if (req->body_len > 0) { + /* 对于 JSON 类型,尝试回显 body */ + if (strstr(req->content_type, "application/json") != NULL) { + n += snprintf(response + n, sizeof(response) - n, ",\"body\": "); + /* 直接拼接 JSON body(假设客户端发送的是合法 JSON) */ + size_t body_copy = req->body_len; + if (body_copy > sizeof(response) - n - 64) { + body_copy = sizeof(response) - n - 64; + } + memcpy(response + n, req->body, body_copy); + n += (int)body_copy; + n += snprintf(response + n, sizeof(response) - n, "}"); + } else if (strstr(req->content_type, "x-www-form-urlencoded") != NULL) { + n += snprintf(response + n, sizeof(response) - n, ",\"body\":\""); + size_t body_copy = req->body_len; + if (body_copy > sizeof(response) - n - 64) { + body_copy = sizeof(response) - n - 64; + } + memcpy(response + n, req->body, body_copy); + n += (int)body_copy; + n += snprintf(response + n, sizeof(response) - n, "\"}"); + } else { + n += snprintf(response + n, sizeof(response) - n, "}"); + } + } else { + n += snprintf(response + n, sizeof(response) - n, "}"); + } + + char header[512]; + int header_len = snprintf(header, sizeof(header), + "HTTP/1.1 200 OK\r\n" + "Content-Type: application/json\r\n" + "Content-Length: %d\r\n" + "Connection: %s\r\n" + "Server: Cocoon/1.0\r\n" + "\r\n", + n, req->keep_alive ? "keep-alive" : "close"); + + send_all(fd, header, (size_t)header_len); + send_all(fd, response, (size_t)n); + + return req->keep_alive; +} + /** * handle_request - 处理单个 HTTP 请求 * * 从缓冲区解析请求,判断是文件还是目录,调用对应的服务函数。 + * 新增:支持 POST 请求体读取和简单回显。 * * @param conn 连接上下文 * @param root_dir 静态资源根目录 @@ -155,9 +280,26 @@ static bool handle_request(connection_t *conn, const char *root_dir) { } conn->buf_len -= (size_t)parsed; + /* 读取请求体(如果需要) */ + if (req.content_length > 0) { + size_t need = (size_t)req.content_length; + if (conn_read_body(conn, &req, need) != 0) { + static_send_error(conn->fd, 413, req.keep_alive); /* Payload Too Large */ + return req.keep_alive; + } + } + + /* 处理 POST */ + if (req.method == HTTP_POST) { + bool keep = handle_post_request(conn->fd, &req); + http_request_free(&req); + return keep; + } + /* 只支持 GET 和 HEAD */ if (req.method != HTTP_GET && req.method != HTTP_HEAD) { static_send_error(conn->fd, 405, req.keep_alive); + http_request_free(&req); return req.keep_alive; } @@ -172,6 +314,7 @@ static bool handle_request(connection_t *conn, const char *root_dir) { int n = snprintf(real_path, sizeof(real_path), "%s%s", root_normalized, req.path); if (n < 0 || (size_t)n >= sizeof(real_path)) { static_send_error(conn->fd, 400, req.keep_alive); + http_request_free(&req); return req.keep_alive; } @@ -181,6 +324,7 @@ static bool handle_request(connection_t *conn, const char *root_dir) { if (!realpath(real_path, resolved) || strncmp(resolved, root_normalized, strlen(root_normalized)) != 0) { static_send_error(conn->fd, 403, req.keep_alive); + http_request_free(&req); return req.keep_alive; } snprintf(real_path, sizeof(real_path), "%s", resolved); @@ -190,6 +334,7 @@ static bool handle_request(connection_t *conn, const char *root_dir) { struct stat st; if (stat(real_path, &st) != 0) { static_send_error(conn->fd, 404, req.keep_alive); + http_request_free(&req); return req.keep_alive; } @@ -198,6 +343,7 @@ static bool handle_request(connection_t *conn, const char *root_dir) { char index_path[4096]; if (snprintf(index_path, sizeof(index_path), "%s/index.html", real_path) >= (int)sizeof(index_path)) { static_send_error(conn->fd, 400, req.keep_alive); + http_request_free(&req); return req.keep_alive; } struct stat index_st; @@ -206,6 +352,7 @@ static bool handle_request(connection_t *conn, const char *root_dir) { http_request_t index_req = req; if (snprintf(index_req.path, sizeof(index_req.path), "%s/index.html", req.path) >= (int)sizeof(index_req.path)) { static_send_error(conn->fd, 400, req.keep_alive); + http_request_free(&req); return req.keep_alive; } static_serve_file(conn->fd, &index_req, root_dir); @@ -220,6 +367,7 @@ static bool handle_request(connection_t *conn, const char *root_dir) { static_send_error(conn->fd, 403, req.keep_alive); } + http_request_free(&req); return req.keep_alive; } diff --git a/static.c b/static.c index 491512d..c569b4e 100644 --- a/static.c +++ b/static.c @@ -206,7 +206,7 @@ static bool safe_path_join(char *dst, size_t dst_size, * @param len 数据长度 * @return 0 成功,-1 失败 */ -static int send_all(int fd, const char *buf, size_t len) { +int send_all(int fd, const char *buf, size_t len) { size_t sent = 0; while (sent < len) { ssize_t n = write(fd, buf + sent, len - sent); diff --git a/static.h b/static.h index 920e6b1..22d740d 100644 --- a/static.h +++ b/static.h @@ -10,6 +10,19 @@ #include "http.h" #include +/** + * send_all - 确保缓冲区全部发送 + * + * 使用 write 循环发送,直到全部数据发送完毕或遇到不可恢复错误。 + * 内部辅助函数,也可被其他模块调用。 + * + * @param fd socket 文件描述符 + * @param buf 数据缓冲区 + * @param len 数据长度 + * @return 0 成功,-1 失败 + */ +int send_all(int fd, const char *buf, size_t len); + /** * static_serve_file - 服务单个静态文件 * diff --git a/tests/integration_test.sh b/tests/integration_test.sh index 0ab6488..38e1b1c 100755 --- a/tests/integration_test.sh +++ b/tests/integration_test.sh @@ -213,6 +213,52 @@ assert_304_modified_since() { fi } +assert_post_json() { + local url="$1" + local body="$2" + local expect="$3" + local desc="${4:-$url}" + local resp + resp=$(curl -s -X POST -H "Content-Type: application/json" -d "$body" "$url") + if echo "$resp" | grep -q "$expect"; then + echo " ✓ $desc — 响应包含 '$expect'" + pass + else + echo " ✗ $desc — 响应期望包含 '$expect', 实际: $resp" + fail + fi +} + +assert_post_form() { + local url="$1" + local body="$2" + local expect="$3" + local desc="${4:-$url}" + local resp + resp=$(curl -s -X POST -H "Content-Type: application/x-www-form-urlencoded" -d "$body" "$url") + if echo "$resp" | grep -q "$expect"; then + echo " ✓ $desc — 响应包含 '$expect'" + pass + else + echo " ✗ $desc — 响应期望包含 '$expect', 实际: $resp" + fail + fi +} + +assert_status_405() { + local url="$1" + local desc="${2:-$url}" + local status + status=$(curl -s -o /dev/null -w "%{http_code}" -X PUT "$url") + if [[ "$status" == "405" ]]; then + echo " ✓ $desc — PUT 返回 405" + pass + else + echo " ✗ $desc — 期望 405, 实际 $status" + fail + fi +} + # ===== 测试开始 ===== start_server @@ -277,6 +323,12 @@ assert_header_contains "$BASE/app.js" "Content-Type" "application/javascript" "J assert_header_contains "$BASE/api.json" "Content-Type" "application/json" "JSON MIME" assert_header_contains "$BASE/image.png" "Content-Type" "image/" "PNG MIME" +echo "" +echo "=== POST 请求测试 ===" +assert_post_json "$BASE/api/echo" '{"test": "hello"}' '"test"' "POST JSON 回显" +assert_post_form "$BASE/api/echo" 'name=cocoon&version=1.0' 'cocoon' "POST 表单回显" +assert_status_405 "$BASE/index.html" "PUT 405" + echo "" echo "=== 结果汇总 ===" echo "通过: $PASS"