diff --git a/.github/actions/build-linux-packages/action.yml b/.github/actions/build-linux-packages/action.yml index 7d8d887..9342dd1 100644 --- a/.github/actions/build-linux-packages/action.yml +++ b/.github/actions/build-linux-packages/action.yml @@ -32,7 +32,7 @@ runs: bash -euc ' export DEBIAN_FRONTEND=noninteractive apt-get update - apt-get install -y --no-install-recommends build-essential libcjson-dev + apt-get install -y --no-install-recommends build-essential libcjson-dev zlib1g-dev make clean make libchttpx.so cp libchttpx.so dist/deb/libchttpx.so @@ -47,7 +47,7 @@ runs: -w /work \ fedora:42 \ bash -euc ' - dnf install -y gcc make cjson-devel + dnf install -y gcc make cjson-devel zlib-devel make clean make libchttpx.so cp libchttpx.so dist/rpm/libchttpx.so @@ -62,7 +62,7 @@ runs: -w /work \ archlinux:base-devel \ bash -euc ' - pacman -Sy --noconfirm cjson + pacman -Sy --noconfirm cjson zlib make clean make libchttpx.so cp libchttpx.so dist/archlinux/libchttpx.so @@ -77,7 +77,7 @@ runs: -w /work \ alpine:3.22 \ sh -euc ' - apk add --no-cache build-base cjson-dev + apk add --no-cache build-base cjson-dev zlib-dev make clean make libchttpx.so cp libchttpx.so dist/apk/libchttpx.so @@ -109,6 +109,7 @@ runs: "Description: A compact cross-platform HTTP/1.1 server library for C/C++.", f"Version: {version}", "Libs: -L${libdir} -lchttpx", + "Libs.private: -lz", "Cflags: -I${includedir}/libchttpx", "", ]) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 064c502..0a78518 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,7 +20,7 @@ jobs: - name: Install dependencies run: | sudo apt update - sudo apt install -y gcc make libcjson-dev clang-format + sudo apt install -y gcc make libcjson-dev zlib1g-dev clang-format - name: Build binary run: make lin @@ -47,7 +47,7 @@ jobs: - name: Install dependencies run: | sudo apt update - sudo apt install -y gcc make libcjson-dev libssl-dev openssl + sudo apt install -y gcc make libcjson-dev zlib1g-dev libssl-dev openssl - name: Build TLS-enabled shared library run: make TLS=1 libchttpx.so @@ -58,6 +58,35 @@ jobs: - name: Run HTTPS integration test run: make test-tls + build-linux-compression: + name: Response compression integration (Linux, zlib) + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install dependencies + run: | + sudo apt update + sudo apt install -y gcc make libcjson-dev zlib1g-dev libssl-dev + + - name: Build shared library + run: make libchttpx.so + + - name: Build compression example + run: make examples-compression + + - name: Run compression integration tests + run: make test-compression + + - name: Run compression benchmark + run: make benchmark-compression + + - name: Verify TLS build keeps compression support + run: | + make clean + make TLS=1 libchttpx.so + build-windows: name: Build (Windows) runs-on: windows-latest @@ -76,6 +105,7 @@ jobs: install: >- mingw-w64-x86_64-gcc mingw-w64-x86_64-make + mingw-w64-x86_64-zlib - name: Build run: mingw32-make win SHELL=cmd diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 6184ada..168e1e0 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -33,7 +33,7 @@ jobs: - name: Install dependencies run: | sudo apt update - sudo apt install -y gcc make libcjson-dev + sudo apt install -y gcc make libcjson-dev zlib1g-dev - name: Build run: make libchttpx.so diff --git a/Dockerfile b/Dockerfile index 0a0d60c..f6231d2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,6 +4,7 @@ RUN apt-get update \ && apt-get install -y --no-install-recommends \ build-essential \ libcjson-dev \ + zlib1g-dev \ && rm -rf /var/lib/apt/lists/* WORKDIR /src @@ -18,6 +19,7 @@ FROM debian:bookworm-slim RUN apt-get update \ && apt-get install -y --no-install-recommends \ libcjson1 \ + zlib1g \ && rm -rf /var/lib/apt/lists/* COPY --from=builder /pkg/usr/local/lib/libchttpx.so /usr/local/lib/ diff --git a/Makefile b/Makefile index 29f0f96..552fe02 100644 --- a/Makefile +++ b/Makefile @@ -29,13 +29,15 @@ PKGDIR ?= /pkg/usr/local WIN_LIB_DIR = tools -LIN_LDFLAGS = -lcjson $(TLS_LDFLAGS) -WIN_LDFLAGS = -lws2_32 $(TLS_LDFLAGS) +LIN_LDFLAGS = -lcjson -lz $(TLS_LDFLAGS) +WIN_LDFLAGS = -lws2_32 -lz $(TLS_LDFLAGS) TEST_TARGET = $(BINDIR)/test_core TEST_SERVER_TARGET = $(BINDIR)/test_server TEST_SANITIZE_TARGET = $(BINDIR)/test_core_sanitize TEST_SERVER_SANITIZE_TARGET = $(BINDIR)/test_server_sanitize TEST_TLS_TARGET = $(BINDIR)/test_tls +TEST_COMPRESSION_TARGET = $(BINDIR)/test_compression +COMPRESSION_BENCHMARK_TARGET = $(BINDIR)/benchmark-compression EXAMPLE_SRC = example/basic.c EXAMPLE_OBJ = $(OBJDIR)/example/basic.o @@ -73,6 +75,8 @@ $(BINDIR)/example-%: example/%.c $(LIN_SRCS) examples-tls: @$(MAKE) TLS=1 $(BINDIR)/example-tls +examples-compression: $(BINDIR)/example-compression + # WINdows build # - @@ -108,12 +112,13 @@ lib-install: libchttpx.so win-lib: @echo "Building Windows DLL..." if not exist $(BINDIR) mkdir $(BINDIR) - $(CC) $(CFLAGS) -std=c11 -D_WIN32 -shared -o $(BINDIR)/$(TARGET_DLL) $(WIN_SRCS) -Wl,--out-implib,$(BINDIR)/libchttpx.a -lws2_32 + $(CC) $(CFLAGS) -std=c11 -D_WIN32 -shared -o $(BINDIR)/$(TARGET_DLL) $(WIN_SRCS) -Wl,--out-implib,$(BINDIR)/libchttpx.a $(WIN_LDFLAGS) @echo "Copying files to $(WIN_LIB_DIR)..." if not exist $(WIN_LIB_DIR) mkdir $(WIN_LIB_DIR) copy /Y $(BINDIR)\$(TARGET_DLL) $(WIN_LIB_DIR)\$(TARGET_DLL) copy /Y $(BINDIR)\libchttpx.a $(WIN_LIB_DIR)\libchttpx.a + for /f "delims=" %%i in ('where zlib1.dll 2^>nul') do copy /Y "%%i" $(WIN_LIB_DIR)\zlib1.dll if not exist $(WIN_LIB_DIR)\include mkdir $(WIN_LIB_DIR)\include xcopy /E /I /Y include $(WIN_LIB_DIR)\include @@ -160,6 +165,20 @@ $(TEST_TLS_TARGET): tests/test_tls.c $(LIN_SRCS) @mkdir -p $(BINDIR) $(CC) $(CFLAGS) -std=gnu11 -g tests/test_tls.c $(LIN_SRCS) -o $@ $(LIN_LDFLAGS) -pthread +test-compression: $(TEST_COMPRESSION_TARGET) + $(TEST_COMPRESSION_TARGET) + +$(TEST_COMPRESSION_TARGET): tests/test_compression.c $(LIN_SRCS) + @mkdir -p $(BINDIR) + $(CC) $(CFLAGS) -std=gnu11 -g tests/test_compression.c $(LIN_SRCS) -o $@ $(LIN_LDFLAGS) -pthread + +benchmark-compression: $(COMPRESSION_BENCHMARK_TARGET) + $(COMPRESSION_BENCHMARK_TARGET) + +$(COMPRESSION_BENCHMARK_TARGET): benchmarks/compression.c $(LIN_SRCS) + @mkdir -p $(BINDIR) + $(CC) $(CFLAGS) -std=gnu11 -O2 benchmarks/compression.c $(LIN_SRCS) -o $@ $(LIN_LDFLAGS) -pthread + # LINux lib compile # - diff --git a/README.md b/README.md index 18d16fa..90a2749 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ The library is designed so handlers contain application logic instead of repetit - multipart forms, multiple uploads, upload policies, and temporary-file cleanup - request IDs and `Accept-Language` negotiation - CORS, cookies, logging callbacks, and rate limiting +- optional gzip response compression with `Accept-Encoding` negotiation - configurable server limits and graceful shutdown > `cHTTPX_ResFile()` currently reads the complete file into memory. Streaming responses, `sendfile()`, and zero-copy output are not implemented in the current API. @@ -124,11 +125,11 @@ CMD ["/usr/local/bin/my-server"] ### Build from source on Linux -Requirements: GCC, Make, pkg-config, and cJSON development files. +Requirements: GCC, Make, pkg-config, cJSON development files, and zlib development files. ```bash sudo apt update -sudo apt install -y build-essential pkg-config libcjson-dev +sudo apt install -y build-essential pkg-config libcjson-dev zlib1g-dev git clone https://github.com/netcorelink/libchttpx.git cd libchttpx @@ -154,9 +155,18 @@ make test-tls See [Native TLS / HTTPS](docs/tls/README.md) for server certificates, HTTPS remote calls, custom CA verification, and mTLS. +Response compression is included in the standard build. zlib is a normal libchttpx dependency; applications enable compression at runtime with `cHTTPX_CompressionUse()` and configure minimum size, level, MIME policy, or custom providers in code. + +```bash +make test-compression +make benchmark-compression +``` + +TLS remains independently optional: `make TLS=1 libchttpx.so`. See [Response compression](docs/compression/README.md). + ### Build from source on Windows -Use MinGW/GCC. The Windows build uses the bundled `lib/cjson` source. +Use MinGW/GCC. The Windows build uses the bundled `lib/cjson` source and requires zlib (for MSYS2/MinGW64: `mingw-w64-x86_64-zlib`). ```powershell git clone https://github.com/netcorelink/libchttpx.git @@ -176,6 +186,7 @@ Detailed documentation is split by functionality: - [App runtime, multiple servers, AppRemote, Call and CallEx](docs/app/README.md) - [Server configuration, limits, lifecycle and error codes](docs/server/README.md) - [Native TLS / HTTPS](docs/tls/README.md) +- [Response compression](docs/compression/README.md) - [Routing and route groups](docs/routing/README.md) - [Middleware](docs/middleware/README.md) - [Requests, headers, params, queries and body access](docs/request/README.md) diff --git a/README_RU.md b/README_RU.md index b3f76f6..906d9a8 100644 --- a/README_RU.md +++ b/README_RU.md @@ -17,6 +17,7 @@ - multipart forms, несколько файлов, upload policy и автоматическое удаление временных файлов - request ID и `Accept-Language` negotiation - CORS, cookies, callback-based logging и rate limiting +- optional gzip-сжатие ответов с `Accept-Encoding` negotiation - лимиты сервера и graceful shutdown > `cHTTPX_ResFile()` пока полностью читает файл в память. Streaming response, `sendfile()` и zero-copy output в текущем API не реализованы. @@ -124,7 +125,7 @@ CMD ["/usr/local/bin/my-server"] ```bash sudo apt update -sudo apt install -y build-essential pkg-config libcjson-dev +sudo apt install -y build-essential pkg-config libcjson-dev zlib1g-dev git clone https://github.com/netcorelink/libchttpx.git cd libchttpx @@ -150,9 +151,18 @@ make test-tls Настройка server certificate, HTTPS remote calls, custom CA и mTLS описана в [Native TLS / HTTPS](docs/tls/README_RU.md). +Сжатие ответов входит в обычную сборку libchttpx. zlib — стандартная зависимость библиотеки, а включение gzip выполняется уже в коде приложения через `cHTTPX_CompressionUse()`. Размер, level, MIME policy и providers тоже задаются через config. + +```bash +make test-compression +make benchmark-compression +``` + +TLS остаётся отдельной опцией сборки: `make TLS=1 libchttpx.so`. Подробнее: [Сжатие HTTP-ответов](docs/compression/README_RU.md). + ### Самостоятельная сборка на Windows -Используется MinGW/GCC. Для Windows cJSON уже находится в `lib/cjson`. +Используется MinGW/GCC. Для Windows cJSON уже находится в `lib/cjson`, а zlib должен быть установлен (для MSYS2/MinGW64: `mingw-w64-x86_64-zlib`). ```powershell git clone https://github.com/netcorelink/libchttpx.git @@ -172,6 +182,7 @@ DLL, import library и headers копируются в `tools/`. - [App runtime, несколько серверов, AppRemote, Call и CallEx](docs/app/README_RU.md) - [Конфигурация сервера, лимиты, lifecycle и error codes](docs/server/README_RU.md) - [Native TLS / HTTPS](docs/tls/README_RU.md) +- [Сжатие HTTP-ответов](docs/compression/README_RU.md) - [Routing и route groups](docs/routing/README_RU.md) - [Middleware](docs/middleware/README_RU.md) - [Requests, headers, params, queries и body](docs/request/README_RU.md) diff --git a/benchmarks/compression.c b/benchmarks/compression.c new file mode 100644 index 0000000..d01a2b8 --- /dev/null +++ b/benchmarks/compression.c @@ -0,0 +1,107 @@ +#include +#include +#include +#include + +#include + +#define PAYLOAD_SIZE (1024 * 1024) +#define ITERATIONS 100 + +static double elapsed_seconds(struct timespec start, struct timespec end) +{ + return (double)(end.tv_sec - start.tv_sec) + (double)(end.tv_nsec - start.tv_nsec) / 1000000000.0; +} + +static void build_payload(unsigned char* payload, size_t size) +{ + static const char row[] = + "{\"id\":12345,\"username\":\"artem\",\"active\":true," + "\"message\":\"response compression benchmark payload\"}\n"; + + size_t offset = 0; + while (offset < size) + { + size_t remaining = size - offset; + size_t chunk = sizeof(row) - 1; + if (chunk > remaining) + chunk = remaining; + memcpy(payload + offset, row, chunk); + offset += chunk; + } +} + +static int gzip_once(const unsigned char* input, size_t input_size, int level, unsigned char* output, size_t output_capacity, size_t* output_size) +{ + z_stream stream; + memset(&stream, 0, sizeof(stream)); + + if (deflateInit2(&stream, level, Z_DEFLATED, 15 + 16, 8, Z_DEFAULT_STRATEGY) != Z_OK) + return 0; + + stream.next_in = (Bytef*)input; + stream.avail_in = (uInt)input_size; + stream.next_out = output; + stream.avail_out = (uInt)output_capacity; + + int result = deflate(&stream, Z_FINISH); + if (result != Z_STREAM_END) + { + deflateEnd(&stream); + return 0; + } + + *output_size = output_capacity - stream.avail_out; + deflateEnd(&stream); + return 1; +} + +int main(void) +{ + unsigned char* payload = malloc(PAYLOAD_SIZE); + if (!payload) + return 1; + build_payload(payload, PAYLOAD_SIZE); + + uLong bound = compressBound(PAYLOAD_SIZE) + 64; + unsigned char* compressed = malloc((size_t)bound); + if (!compressed) + { + free(payload); + return 1; + } + + const int levels[] = {1, 5, 9}; + puts("level,input_bytes,compressed_bytes,ratio,throughput_mib_s"); + + for (size_t level_index = 0; level_index < sizeof(levels) / sizeof(levels[0]); level_index++) + { + int level = levels[level_index]; + size_t compressed_size = 0; + + struct timespec start; + struct timespec end; + clock_gettime(CLOCK_MONOTONIC, &start); + + for (int iteration = 0; iteration < ITERATIONS; iteration++) + { + if (!gzip_once(payload, PAYLOAD_SIZE, level, compressed, (size_t)bound, &compressed_size)) + { + free(compressed); + free(payload); + return 1; + } + } + + clock_gettime(CLOCK_MONOTONIC, &end); + double seconds = elapsed_seconds(start, end); + double mib = ((double)PAYLOAD_SIZE * ITERATIONS) / (1024.0 * 1024.0); + double ratio = (double)compressed_size / (double)PAYLOAD_SIZE; + + printf("%d,%d,%zu,%.4f,%.2f\n", level, PAYLOAD_SIZE, compressed_size, ratio, mib / seconds); + } + + free(compressed); + free(payload); + return 0; +} diff --git a/docs/README.md b/docs/README.md index 721020d..4f41b64 100644 --- a/docs/README.md +++ b/docs/README.md @@ -7,6 +7,7 @@ The root README contains only the project overview, installation methods, and na | App runtime | [app](app/README.md) | `cHTTPX_App`, multiple servers, remote registry, `Call`, `CallEx` | | Server | [server](server/README.md) | config, limits, lifecycle, errors, shutdown | | TLS / HTTPS | [tls](tls/README.md) | optional OpenSSL build, HTTPS server/client, CA verification, mTLS | +| Compression | [compression](compression/README.md) | Accept-Encoding negotiation, optional gzip, MIME policy, providers | | Routing | [routing](routing/README.md) | routers, groups, method helpers, path parameters | | Middleware | [middleware](middleware/README.md) | global/router/route before and after middleware | | Request | [request](request/README.md) | metadata, headers, params, queries, bearer tokens, body access | diff --git a/docs/README_RU.md b/docs/README_RU.md index 4a9e69f..22bb059 100644 --- a/docs/README_RU.md +++ b/docs/README_RU.md @@ -7,6 +7,7 @@ | App runtime | [app](app/README_RU.md) | `cHTTPX_App`, несколько серверов, remote registry, `Call`, `CallEx` | | Server | [server](server/README_RU.md) | config, limits, lifecycle, error codes, shutdown | | TLS / HTTPS | [tls](tls/README_RU.md) | optional OpenSSL build, HTTPS server/client, CA verification, mTLS | +| Compression | [compression](compression/README_RU.md) | Accept-Encoding negotiation, optional gzip, MIME policy, providers | | Routing | [routing](routing/README_RU.md) | routers, groups, method helpers, path parameters | | Middleware | [middleware](middleware/README_RU.md) | global/router/route middleware, before/after | | Request | [request](request/README_RU.md) | metadata, headers, params, queries, bearer token, body | diff --git a/docs/compression/README.md b/docs/compression/README.md new file mode 100644 index 0000000..783d408 --- /dev/null +++ b/docs/compression/README.md @@ -0,0 +1,197 @@ +# Response compression + +libchttpx can compress buffered HTTP responses after the route handler and before the response is written to the socket. The built-in provider is gzip through zlib; the provider API is intentionally generic so Brotli or Zstandard providers can be added without changing route handlers. + +## Availability + +gzip support is part of the standard libchttpx build. zlib is a regular library dependency, so there is no separate compression build mode or compression-specific build flag. + +Compression itself is still optional at runtime: nothing is compressed until the application calls `cHTTPX_CompressionUse()`. + +For a source build on Debian/Ubuntu, install zlib together with the other development dependencies: + +```bash +sudo apt install -y build-essential libcjson-dev zlib1g-dev +make libchttpx.so +make test-compression +``` + +TLS remains independently optional: + +```bash +make TLS=1 libchttpx.so +``` + +## Basic setup + +```c +chttpx_compression_config_t compression = cHTTPX_CompressionDefault(); +compression.min_size = 1024; +compression.level = 5; + +int result = cHTTPX_CompressionUse(server, &compression); +if (result != CHTTPX_OK) { + /* handle configuration / provider error */ +} +``` + +Configure compression before starting the server. + +Default values: + +- minimum body size: 1024 bytes +- gzip level: 5 +- provider: built-in gzip +- text, JSON, JavaScript, XML, SVG and similar textual MIME types are compressible +- already compressed images, audio/video, fonts, archives, PDF, WASM and generic octet streams are excluded + +## Accept-Encoding negotiation + +The middleware parses `Accept-Encoding` case-insensitively, including quality values and the wildcard. + +Examples: + +```text +Accept-Encoding: gzip +Accept-Encoding: gzip;q=0.8, identity;q=1 +Accept-Encoding: br;q=1, gzip;q=0.7, identity;q=0.2 +Accept-Encoding: *;q=1, identity;q=0 +``` + +A coding with `q=0` is not selected. When identity has a higher quality than the available provider, the original response is sent. If no supported coding and no identity representation are acceptable, libchttpx returns `406 Not Acceptable`. + +When compression is applied, libchttpx adds: + +```http +Content-Encoding: gzip +Vary: Accept-Encoding +``` + +`Content-Length` is generated from the compressed body size when the response is sent. + +## MIME include/exclude policy + +The default MIME policy is intentionally conservative. Exclusions win over inclusions. + +You can replace either list: + +```c +const char *include[] = { + "text/*", + "application/json", + "application/*+json", +}; + +const char *exclude[] = { + "text/event-stream", +}; + +chttpx_compression_config_t compression = cHTTPX_CompressionDefault(); +compression.include_types = include; +compression.include_types_count = CHTTPX_ARRAY_LEN(include); +compression.exclude_types = exclude; +compression.exclude_types_count = CHTTPX_ARRAY_LEN(exclude); + +cHTTPX_CompressionUse(server, &compression); +``` + +Patterns support `*`, including suffix patterns such as `application/*+json`. + +## Responses that are not transformed + +Compression is skipped when transforming the response would be inappropriate, including: + +- no response body +- `HEAD` +- informational responses and `204`, `205`, `206`, `304` +- range requests / `Content-Range` +- an existing `Content-Encoding` +- `Cache-Control: no-transform` +- bodies below `min_size` +- excluded/non-included MIME types + +This keeps range semantics and application-provided encodings intact. + +## Disable compression for a route or response + +Per route: + +```c +chttpx_route_t *route = cHTTPX_Get(&router, "/download", download_handler); +cHTTPX_RouteCompression(route, false); +``` + +Per response: + +```c +static void handler(chttpx_request_t *req, chttpx_response_t *res) +{ + (void)req; + *res = cHTTPX_ResJson(cHTTPX_StatusOK, "{\"ok\":true}"); + cHTTPX_ResponseCompression(res, false); +} +``` + +## Custom providers + +A provider receives one complete buffered response and returns an allocated encoded buffer: + +```c +static int encode_zstd( + const unsigned char *input, + size_t input_size, + int level, + unsigned char **output, + size_t *output_size, + void *user_data) +{ + /* allocate *output and encode the complete input */ + return CHTTPX_OK; +} + +chttpx_compression_provider_t providers[] = { + { + .encoding = "zstd", + .encode_buffer = encode_zstd, + .user_data = NULL, + }, +}; + +chttpx_compression_config_t compression = cHTTPX_CompressionDefault(); +compression.providers = providers; +compression.providers_count = CHTTPX_ARRAY_LEN(providers); + +cHTTPX_CompressionUse(server, &compression); +``` + +Providers are evaluated using the client's quality values. When qualities tie, provider configuration order is used. The current callback is deliberately buffer-oriented; a future streaming provider interface can be added without changing response policy or negotiation configuration. + +## Errors and logging + +Invalid configuration returns `CHTTPX_ERR_INVALID_ARGUMENT`; allocation failures return `CHTTPX_ERR_MEMORY`; gzip/provider failures use `CHTTPX_ERR_COMPRESSION` internally. + +If encoding fails and identity is acceptable, libchttpx logs a warning and sends the original response. If identity is forbidden, it sends an empty `500 Internal Server Error` rather than silently violating `Accept-Encoding`. + +## Benchmark + +Run: + +```bash +make benchmark-compression +``` + +The benchmark compresses a 1 MiB JSON-like payload repeatedly at gzip levels 1, 5 and 9 and prints CSV columns: + +```text +level,input_bytes,compressed_bytes,ratio,throughput_mib_s +``` + +Use `ratio` as the bandwidth-saving side of the tradeoff and `throughput_mib_s` as the CPU-cost side. Results depend on CPU, compiler, zlib version and payload, so the repository does not hard-code a universal “best” level. + +## Example + +```bash +make examples-compression +./.build/example-compression +curl --compressed -i http://127.0.0.1:8080/ +``` diff --git a/docs/compression/README_RU.md b/docs/compression/README_RU.md new file mode 100644 index 0000000..5c274c7 --- /dev/null +++ b/docs/compression/README_RU.md @@ -0,0 +1,197 @@ +# Сжатие HTTP-ответов + +libchttpx умеет сжимать buffered HTTP-ответ после выполнения handler и до отправки ответа в сокет. Встроенный provider — gzip через zlib. Provider API сделан универсальным, чтобы позже можно было добавить Brotli или Zstandard без изменения handler'ов. + +## Доступность + +gzip входит в обычную сборку libchttpx. zlib является стандартной зависимостью библиотеки, поэтому отдельного режима сборки и специального build-флага для compression нет. + +Само сжатие остаётся опциональным во время выполнения: пока приложение не вызовет `cHTTPX_CompressionUse()`, ответы не сжимаются. + +Для сборки из исходников на Debian/Ubuntu zlib устанавливается вместе с остальными development-зависимостями: + +```bash +sudo apt install -y build-essential libcjson-dev zlib1g-dev +make libchttpx.so +make test-compression +``` + +TLS остаётся отдельной опцией: + +```bash +make TLS=1 libchttpx.so +``` + +## Базовая настройка + +```c +chttpx_compression_config_t compression = cHTTPX_CompressionDefault(); +compression.min_size = 1024; +compression.level = 5; + +int result = cHTTPX_CompressionUse(server, &compression); +if (result != CHTTPX_OK) { + /* обработка ошибки */ +} +``` + +Настраивать compression лучше до запуска сервера. + +Значения по умолчанию: + +- минимальный размер body — 1024 байта +- gzip level — 5 +- provider — встроенный gzip +- текст, JSON, JavaScript, XML, SVG и похожие текстовые MIME сжимаются +- уже сжатые изображения, audio/video, fonts, архивы, PDF, WASM и `application/octet-stream` исключены + +## Negotiation через Accept-Encoding + +Middleware разбирает `Accept-Encoding` без учёта регистра, включая quality values и wildcard. + +Примеры: + +```text +Accept-Encoding: gzip +Accept-Encoding: gzip;q=0.8, identity;q=1 +Accept-Encoding: br;q=1, gzip;q=0.7, identity;q=0.2 +Accept-Encoding: *;q=1, identity;q=0 +``` + +Кодировка с `q=0` не выбирается. Если у `identity` quality выше, чем у доступного provider, отправляется исходный body. Если клиент не принимает ни поддерживаемое сжатие, ни identity, libchttpx отвечает `406 Not Acceptable`. + +При gzip-сжатии автоматически добавляются: + +```http +Content-Encoding: gzip +Vary: Accept-Encoding +``` + +`Content-Length` при отправке вычисляется уже по размеру сжатого body. + +## Include/exclude для MIME + +Политика по умолчанию специально консервативная. Exclude имеет приоритет над include. + +Списки можно заменить: + +```c +const char *include[] = { + "text/*", + "application/json", + "application/*+json", +}; + +const char *exclude[] = { + "text/event-stream", +}; + +chttpx_compression_config_t compression = cHTTPX_CompressionDefault(); +compression.include_types = include; +compression.include_types_count = CHTTPX_ARRAY_LEN(include); +compression.exclude_types = exclude; +compression.exclude_types_count = CHTTPX_ARRAY_LEN(exclude); + +cHTTPX_CompressionUse(server, &compression); +``` + +В pattern поддерживается `*`, в том числе `application/*+json`. + +## Какие ответы не сжимаются + +Compression пропускается, когда преобразование ответа нежелательно или ломает HTTP semantics: + +- body отсутствует +- метод `HEAD` +- informational response, а также `204`, `205`, `206`, `304` +- range request / `Content-Range` +- уже есть `Content-Encoding` +- `Cache-Control: no-transform` +- body меньше `min_size` +- MIME не входит в include или входит в exclude + +Так сохраняется корректная работа range response и уже заданного приложением encoding. + +## Отключение для route или конкретного response + +Для route: + +```c +chttpx_route_t *route = cHTTPX_Get(&router, "/download", download_handler); +cHTTPX_RouteCompression(route, false); +``` + +Для конкретного response: + +```c +static void handler(chttpx_request_t *req, chttpx_response_t *res) +{ + (void)req; + *res = cHTTPX_ResJson(cHTTPX_StatusOK, "{\"ok\":true}"); + cHTTPX_ResponseCompression(res, false); +} +``` + +## Собственный provider + +Provider получает целиком buffered body и возвращает выделенный encoded buffer: + +```c +static int encode_zstd( + const unsigned char *input, + size_t input_size, + int level, + unsigned char **output, + size_t *output_size, + void *user_data) +{ + /* выделить *output и закодировать input */ + return CHTTPX_OK; +} + +chttpx_compression_provider_t providers[] = { + { + .encoding = "zstd", + .encode_buffer = encode_zstd, + .user_data = NULL, + }, +}; + +chttpx_compression_config_t compression = cHTTPX_CompressionDefault(); +compression.providers = providers; +compression.providers_count = CHTTPX_ARRAY_LEN(providers); + +cHTTPX_CompressionUse(server, &compression); +``` + +Provider выбирается по quality клиента. При одинаковом quality используется порядок providers в config. Сейчас callback рассчитан на buffered body; в будущем streaming provider API можно добавить отдельно, не меняя конфигурацию negotiation и policy. + +## Ошибки и logging + +Некорректная конфигурация возвращает `CHTTPX_ERR_INVALID_ARGUMENT`, ошибка памяти — `CHTTPX_ERR_MEMORY`, а ошибка compression provider — `CHTTPX_ERR_COMPRESSION` внутри middleware. + +Если provider не смог сжать body, но клиент принимает identity, libchttpx пишет warning в logger и отправляет исходный ответ. Если identity запрещён, возвращается пустой `500 Internal Server Error`, а не ответ с неподдерживаемым encoding. + +## Benchmark: CPU против bandwidth + +Запуск: + +```bash +make benchmark-compression +``` + +Benchmark многократно сжимает JSON-like payload размером 1 MiB на gzip levels 1, 5 и 9 и печатает CSV: + +```text +level,input_bytes,compressed_bytes,ratio,throughput_mib_s +``` + +`ratio` показывает экономию трафика, а `throughput_mib_s` — стоимость по CPU. Результат зависит от процессора, compiler, версии zlib и данных, поэтому универсальный «лучший» level в документации не фиксируется. + +## Пример + +```bash +make examples-compression +./.build/example-compression +curl --compressed -i http://127.0.0.1:8080/ +``` diff --git a/example/compression.c b/example/compression.c new file mode 100644 index 0000000..6b1eb99 --- /dev/null +++ b/example/compression.c @@ -0,0 +1,70 @@ +#include + +#include + +static const char page[] = + "" + "

libchttpx compression example

" + "

This intentionally repetitive response demonstrates gzip negotiation.

" + "

This intentionally repetitive response demonstrates gzip negotiation.

" + "

This intentionally repetitive response demonstrates gzip negotiation.

" + "

This intentionally repetitive response demonstrates gzip negotiation.

" + "

This intentionally repetitive response demonstrates gzip negotiation.

" + "

This intentionally repetitive response demonstrates gzip negotiation.

" + "

This intentionally repetitive response demonstrates gzip negotiation.

" + "

This intentionally repetitive response demonstrates gzip negotiation.

" + ""; + +static void page_handler(chttpx_request_t* req, chttpx_response_t* res) +{ + (void)req; + *res = cHTTPX_ResHtml(cHTTPX_StatusOK, "%s", page); +} + +static void health_handler(chttpx_request_t* req, chttpx_response_t* res) +{ + (void)req; + *res = cHTTPX_ResMessage(cHTTPX_StatusOK, "ok"); + cHTTPX_ResponseCompression(res, false); +} + +int main(void) +{ + chttpx_app_t app; + if (cHTTPX_AppInit(&app) != CHTTPX_OK) + return 1; + + chttpx_config_t config = cHTTPX_DefaultConfig(); + config.port = 8080; + + chttpx_serv_t* server = cHTTPX_AppServer(&app, "main", &config); + if (!server) + { + cHTTPX_AppShutdown(&app); + return 1; + } + + chttpx_compression_config_t compression = cHTTPX_CompressionDefault(); + compression.min_size = 256; + compression.level = 5; + + int compression_result = cHTTPX_CompressionUse(server, &compression); + if (compression_result != CHTTPX_OK) + { + fprintf(stderr, "compression configuration failed: %d\n", compression_result); + cHTTPX_AppShutdown(&app); + return 1; + } + + chttpx_router_t router = cHTTPX_RoutePathPrefix(server, ""); + cHTTPX_Get(&router, "/", page_handler); + + chttpx_route_t* health = cHTTPX_Get(&router, "/health", health_handler); + cHTTPX_RouteCompression(health, false); + + printf("Try: curl --compressed -i http://127.0.0.1:8080/\n"); + + int result = cHTTPX_AppRun(&app); + cHTTPX_AppShutdown(&app); + return result == CHTTPX_OK ? 0 : 1; +} diff --git a/include/libchttpx.h b/include/libchttpx.h index c6a229b..4a2b527 100644 --- a/include/libchttpx.h +++ b/include/libchttpx.h @@ -840,6 +840,9 @@ extern "C" chttpx_body_ownership_t body_ownership; + /* Set when response compression must be bypassed. */ + bool compression_disabled; + /* Times for logging */ struct timespec start_ts; struct timespec end_ts; @@ -1185,7 +1188,8 @@ extern "C" CHTTPX_ERR_STATE = -10, CHTTPX_ERR_UNAVAILABLE = -11, CHTTPX_ERR_TIMEOUT = -12, - CHTTPX_ERR_TLS = -13 + CHTTPX_ERR_TLS = -13, + CHTTPX_ERR_COMPRESSION = -14 } chttpx_error_t; typedef enum @@ -1251,7 +1255,7 @@ extern "C" size_t allowed_types_count; } chttpx_upload_policy_t; - typedef struct + typedef struct chttpx_route { const char* method; const char* path; @@ -1262,6 +1266,7 @@ extern "C" size_t after_middleware_count; chttpx_upload_policy_t upload_policy; bool has_upload_policy; + bool compression_disabled; } chttpx_route_t; typedef struct chttpx_serv @@ -1305,6 +1310,7 @@ extern "C" chttpx_middleware_stack_t middleware; bool logging_enabled; void* rate_limiter_state; + void* compression_state; chttpx_cors_t cors; } chttpx_serv_t; @@ -1358,6 +1364,51 @@ extern "C" void _chttpx_server_listen(chttpx_serv_t* server); void _chttpx_server_shutdown(chttpx_serv_t* server); + /** + * Encode one complete buffered response body. + * + * The provider allocates *output with malloc-compatible ownership. The + * library owns that buffer after a successful compression operation. + */ + typedef int (*chttpx_compression_encode_fn)(const unsigned char* input, + size_t input_size, + int level, + unsigned char** output, + size_t* output_size, + void* user_data); + + typedef struct + { + const char* encoding; + chttpx_compression_encode_fn encode_buffer; + void* user_data; + } chttpx_compression_provider_t; + + typedef struct + { + size_t min_size; + int level; + const char** include_types; + size_t include_types_count; + const char** exclude_types; + size_t exclude_types_count; + const chttpx_compression_provider_t* providers; + size_t providers_count; + } chttpx_compression_config_t; + + /** Return the default gzip-oriented response compression configuration. */ + chttpx_compression_config_t cHTTPX_CompressionDefault(void); + + /** Enable response compression for one server. */ + int cHTTPX_CompressionUse(chttpx_serv_t* server, const chttpx_compression_config_t* config); + + /** Enable or disable response compression for one route. */ + int cHTTPX_RouteCompression(chttpx_route_t* route, bool enabled); + + /** Enable or disable compression for one response. */ + void cHTTPX_ResponseCompression(chttpx_response_t* response, bool enabled); + + #ifdef __cplusplus } #endif diff --git a/libchttpx-tls.pc b/libchttpx-tls.pc index eaec986..c4b6d86 100644 --- a/libchttpx-tls.pc +++ b/libchttpx-tls.pc @@ -6,6 +6,6 @@ Name: libchttpx Description: libchttpx with optional native OpenSSL TLS/HTTPS transport enabled. Version: 1.4.1 Libs: -L${libdir} -lchttpx -Libs.private: -lssl -lcrypto +Libs.private: -lssl -lcrypto -lz Requires.private: openssl Cflags: -I${includedir}/libchttpx diff --git a/libchttpx.pc b/libchttpx.pc index f35c989..e9e0692 100644 --- a/libchttpx.pc +++ b/libchttpx.pc @@ -6,4 +6,5 @@ Name: libchttpx Description: A powerful, cross-platform HTTP server library in C/C++ for building full-featured web servers. Version: 1.4.1 Libs: -L${libdir} -lchttpx +Libs.private: -lz Cflags: -I${includedir}/libchttpx \ No newline at end of file diff --git a/packaging/nfpm.yaml b/packaging/nfpm.yaml index 0116af2..b8263b3 100644 --- a/packaging/nfpm.yaml +++ b/packaging/nfpm.yaml @@ -76,12 +76,16 @@ overrides: deb: depends: - libcjson1 + - zlib1g rpm: depends: - cjson + - zlib archlinux: depends: - cjson + - zlib apk: depends: - cjson + - zlib diff --git a/scripts/install.sh b/scripts/install.sh index f435536..7f9aa85 100644 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -34,6 +34,28 @@ fi echo "cjson installed successfully!" +# zlib +if pkg-config --exists zlib 2>/dev/null; then + echo "zlib already installed." +else + echo "zlib not found. Installing...." + + if command -v apt >/dev/null 2>&1; then + sudo apt install -y zlib1g-dev + elif command -v pacman >/dev/null 2>&1; then + sudo pacman -Sy --noconfirm zlib + elif command -v dnf >/dev/null 2>&1; then + sudo dnf install -y zlib-devel + elif command -v zypper >/dev/null 2>&1; then + sudo zypper install -y zlib-devel + else + echo "Unsupported package manager. Install zlib manually." + exit 1 + fi +fi + +echo "zlib installed successfully!" + TMPDIR=$(mktemp -d) echo "Bulding in $TMPDIR" diff --git a/src/cHTTPX_compression.c b/src/cHTTPX_compression.c new file mode 100644 index 0000000..385b80d --- /dev/null +++ b/src/cHTTPX_compression.c @@ -0,0 +1,935 @@ +/* + * Copyright (c) 2026 netcorelink + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software. + */ + +#include "cHTTPX_compression.h" + +#include "cHTTPX_crosspltm.h" +#include "cHTTPX_headers.h" +#include "cHTTPX_http.h" +#include "cHTTPX_middlewares.h" +#include "cHTTPX_request.h" +#include "cHTTPX_response.h" +#include "cHTTPX_serv.h" + +#include +#include +#include +#include + +#include + +typedef struct +{ + chttpx_compression_config_t config; + char** include_types; + char** exclude_types; + chttpx_compression_provider_t* providers; + char** provider_encodings; +} chttpx_compression_state_t; + +static const char* default_include_types[] = { + "text/*", + "application/json", + "application/*+json", + "application/javascript", + "application/x-javascript", + "application/xml", + "application/*+xml", + "application/graphql", + "application/sql", + "image/svg+xml", +}; + +static const char* default_exclude_types[] = { + "image/jpeg", + "image/png", + "image/gif", + "image/webp", + "image/avif", + "image/heic", + "image/heif", + "audio/*", + "video/*", + "font/*", + "application/pdf", + "application/zip", + "application/gzip", + "application/x-gzip", + "application/x-7z-compressed", + "application/x-rar-compressed", + "application/octet-stream", + "application/wasm", +}; + +static int compression_middleware_registered(const chttpx_serv_t* server, chttpx_middleware_t middleware) +{ + if (!server || !middleware) + return 0; + + for (size_t i = 0; i < server->middleware.after_middleware_count; i++) + if (server->middleware.after_middlewares[i] == middleware) + return 1; + + return 0; +} + +static void compression_log(chttpx_request_t* req, chttpx_log_level_t level, const char* message) +{ + chttpx_serv_t* server = req ? req->_server : NULL; + if (!server || !server->logger || server->log_level > level) + return; + + server->logger(level, req->request_id, message, server->logger_data); +} + +static int token_character(unsigned char ch) +{ + if (isalnum(ch)) + return 1; + + switch (ch) + { + case '!': + case '#': + case '$': + case '%': + case '&': + case '\'': + case '*': + case '+': + case '-': + case '.': + case '^': + case '_': + case '`': + case '|': + case '~': + return 1; + default: + return 0; + } +} + +static int valid_encoding_name(const char* encoding) +{ + if (!encoding || !*encoding || strcasecmp(encoding, "identity") == 0) + return 0; + + for (const unsigned char* cursor = (const unsigned char*)encoding; *cursor; cursor++) + if (!token_character(*cursor)) + return 0; + + return 1; +} + +static int duplicate_strings(const char** source, size_t count, char*** destination) +{ + if (!destination || (count > 0 && !source)) + return CHTTPX_ERR_INVALID_ARGUMENT; + + char** copy = count ? calloc(count, sizeof(*copy)) : NULL; + if (count && !copy) + return CHTTPX_ERR_MEMORY; + + for (size_t i = 0; i < count; i++) + { + if (!source[i]) + { + for (size_t j = 0; j < i; j++) + free(copy[j]); + free(copy); + return CHTTPX_ERR_INVALID_ARGUMENT; + } + + copy[i] = strdup(source[i]); + if (!copy[i]) + { + for (size_t j = 0; j < i; j++) + free(copy[j]); + free(copy); + return CHTTPX_ERR_MEMORY; + } + } + + *destination = copy; + return CHTTPX_OK; +} + +static void free_compression_state(chttpx_compression_state_t* state) +{ + if (!state) + return; + + for (size_t i = 0; i < state->config.include_types_count; i++) + free(state->include_types[i]); + free(state->include_types); + + for (size_t i = 0; i < state->config.exclude_types_count; i++) + free(state->exclude_types[i]); + free(state->exclude_types); + + for (size_t i = 0; i < state->config.providers_count; i++) + free(state->provider_encodings[i]); + free(state->provider_encodings); + free(state->providers); + free(state); +} + +static int gzip_encode_buffer(const unsigned char* input, + size_t input_size, + int level, + unsigned char** output, + size_t* output_size, + void* user_data) +{ + (void)user_data; + + if (!input || input_size == 0 || !output || !output_size || level < -1 || level > 9) + return CHTTPX_ERR_INVALID_ARGUMENT; + + if (input_size > (size_t)ULONG_MAX) + return CHTTPX_ERR_LIMIT; + + z_stream stream; + memset(&stream, 0, sizeof(stream)); + + int zresult = deflateInit2(&stream, level, Z_DEFLATED, 15 + 16, 8, Z_DEFAULT_STRATEGY); + if (zresult != Z_OK) + return CHTTPX_ERR_COMPRESSION; + + uLong bound_value = deflateBound(&stream, (uLong)input_size); + if (bound_value == 0 || (uintmax_t)bound_value > (uintmax_t)SIZE_MAX) + { + deflateEnd(&stream); + return CHTTPX_ERR_LIMIT; + } + + size_t capacity = (size_t)bound_value; + unsigned char* buffer = malloc(capacity); + if (!buffer) + { + deflateEnd(&stream); + return CHTTPX_ERR_MEMORY; + } + + size_t input_offset = 0; + size_t output_offset = 0; + + while (input_offset < input_size) + { + size_t remaining = input_size - input_offset; + uInt chunk = remaining > UINT_MAX ? UINT_MAX : (uInt)remaining; + + stream.next_in = (Bytef*)(input + input_offset); + stream.avail_in = chunk; + + while (stream.avail_in > 0) + { + if (output_offset >= capacity) + goto compression_error; + + size_t output_remaining = capacity - output_offset; + uInt output_chunk = output_remaining > UINT_MAX ? UINT_MAX : (uInt)output_remaining; + stream.next_out = buffer + output_offset; + stream.avail_out = output_chunk; + + zresult = deflate(&stream, Z_NO_FLUSH); + if (zresult != Z_OK) + goto compression_error; + + output_offset += (size_t)(output_chunk - stream.avail_out); + if (stream.avail_out == 0 && output_offset >= capacity) + goto compression_error; + } + + input_offset += chunk; + } + + do + { + if (output_offset >= capacity) + goto compression_error; + + size_t output_remaining = capacity - output_offset; + uInt output_chunk = output_remaining > UINT_MAX ? UINT_MAX : (uInt)output_remaining; + stream.next_out = buffer + output_offset; + stream.avail_out = output_chunk; + + zresult = deflate(&stream, Z_FINISH); + output_offset += (size_t)(output_chunk - stream.avail_out); + + if (zresult != Z_OK && zresult != Z_STREAM_END) + goto compression_error; + } while (zresult != Z_STREAM_END); + + deflateEnd(&stream); + *output = buffer; + *output_size = output_offset; + return CHTTPX_OK; + +compression_error: + deflateEnd(&stream); + free(buffer); + return CHTTPX_ERR_COMPRESSION; +} + +chttpx_compression_config_t cHTTPX_CompressionDefault(void) +{ + return (chttpx_compression_config_t){ + .min_size = 1024, + .level = 5, + .include_types = default_include_types, + .include_types_count = sizeof(default_include_types) / sizeof(default_include_types[0]), + .exclude_types = default_exclude_types, + .exclude_types_count = sizeof(default_exclude_types) / sizeof(default_exclude_types[0]), + .providers = NULL, + .providers_count = 0, + }; +} + +static int copy_providers(const chttpx_compression_provider_t* providers, + size_t count, + chttpx_compression_provider_t** providers_out, + char*** encodings_out) +{ + if (!providers_out || !encodings_out || (count > 0 && !providers)) + return CHTTPX_ERR_INVALID_ARGUMENT; + + chttpx_compression_provider_t* provider_copy = count ? calloc(count, sizeof(*provider_copy)) : NULL; + char** encoding_copy = count ? calloc(count, sizeof(*encoding_copy)) : NULL; + if (count && (!provider_copy || !encoding_copy)) + { + free(provider_copy); + free(encoding_copy); + return CHTTPX_ERR_MEMORY; + } + + for (size_t i = 0; i < count; i++) + { + if (!valid_encoding_name(providers[i].encoding) || !providers[i].encode_buffer) + { + for (size_t j = 0; j < i; j++) + free(encoding_copy[j]); + free(provider_copy); + free(encoding_copy); + return CHTTPX_ERR_INVALID_ARGUMENT; + } + + encoding_copy[i] = strdup(providers[i].encoding); + if (!encoding_copy[i]) + { + for (size_t j = 0; j < i; j++) + free(encoding_copy[j]); + free(provider_copy); + free(encoding_copy); + return CHTTPX_ERR_MEMORY; + } + + provider_copy[i] = providers[i]; + provider_copy[i].encoding = encoding_copy[i]; + } + + *providers_out = provider_copy; + *encodings_out = encoding_copy; + return CHTTPX_OK; +} + +static int prepare_compression_state(const chttpx_compression_config_t* config, chttpx_compression_state_t** state_out) +{ + if (!config || !state_out || + (config->include_types_count > 0 && !config->include_types) || + (config->exclude_types_count > 0 && !config->exclude_types) || + (config->providers_count > 0 && !config->providers)) + return CHTTPX_ERR_INVALID_ARGUMENT; + + if (config->providers_count == 0 && (config->level < -1 || config->level > 9)) + return CHTTPX_ERR_INVALID_ARGUMENT; + + chttpx_compression_state_t* state = calloc(1, sizeof(*state)); + if (!state) + return CHTTPX_ERR_MEMORY; + + state->config = *config; + + int result = duplicate_strings(config->include_types, config->include_types_count, &state->include_types); + if (result != CHTTPX_OK) + goto error; + state->config.include_types = (const char**)state->include_types; + + result = duplicate_strings(config->exclude_types, config->exclude_types_count, &state->exclude_types); + if (result != CHTTPX_OK) + goto error; + state->config.exclude_types = (const char**)state->exclude_types; + + if (config->providers_count > 0) + { + result = copy_providers(config->providers, config->providers_count, &state->providers, &state->provider_encodings); + if (result != CHTTPX_OK) + goto error; + } + else + { + chttpx_compression_provider_t gzip_provider = { + .encoding = "gzip", + .encode_buffer = gzip_encode_buffer, + .user_data = NULL, + }; + result = copy_providers(&gzip_provider, 1, &state->providers, &state->provider_encodings); + if (result != CHTTPX_OK) + goto error; + state->config.providers_count = 1; + } + + state->config.providers = state->providers; + *state_out = state; + return CHTTPX_OK; + +error: + free_compression_state(state); + return result; +} + +static int wildcard_match_ci(const char* value, size_t value_length, const char* pattern) +{ + if (!value || !pattern) + return 0; + + const char* star = NULL; + size_t star_value = 0; + size_t value_index = 0; + size_t pattern_index = 0; + size_t pattern_length = strlen(pattern); + + while (value_index < value_length) + { + if (pattern_index < pattern_length && + (pattern[pattern_index] == '?' || + tolower((unsigned char)pattern[pattern_index]) == tolower((unsigned char)value[value_index]))) + { + value_index++; + pattern_index++; + continue; + } + + if (pattern_index < pattern_length && pattern[pattern_index] == '*') + { + star = pattern + pattern_index++; + star_value = value_index; + continue; + } + + if (star) + { + pattern_index = (size_t)(star - pattern) + 1; + value_index = ++star_value; + continue; + } + + return 0; + } + + while (pattern_index < pattern_length && pattern[pattern_index] == '*') + pattern_index++; + + return pattern_index == pattern_length; +} + +static int mime_matches(const char* mime, const char* pattern) +{ + if (!mime || !pattern) + return 0; + + const char* semicolon = strchr(mime, ';'); + size_t mime_length = semicolon ? (size_t)(semicolon - mime) : strlen(mime); + while (mime_length > 0 && isspace((unsigned char)mime[mime_length - 1])) + mime_length--; + + return wildcard_match_ci(mime, mime_length, pattern); +} + +static int mime_is_eligible(const chttpx_compression_state_t* state, const char* mime) +{ + if (!state || !mime) + return 0; + + for (size_t i = 0; i < state->config.exclude_types_count; i++) + if (mime_matches(mime, state->config.exclude_types[i])) + return 0; + + if (state->config.include_types_count == 0) + return 1; + + for (size_t i = 0; i < state->config.include_types_count; i++) + if (mime_matches(mime, state->config.include_types[i])) + return 1; + + return 0; +} + +static int response_header_index(const chttpx_response_t* response, const char* name) +{ + if (!response || !name) + return -1; + + for (size_t i = 0; i < response->headers_count; i++) + if (strcasecmp(response->headers[i].name, name) == 0) + return (int)i; + + return -1; +} + +static const char* response_header_get(const chttpx_response_t* response, const char* name) +{ + int index = response_header_index(response, name); + return index >= 0 ? response->headers[index].value : NULL; +} + +static int comma_token_contains(const char* value, const char* token) +{ + if (!value || !token) + return 0; + + const char* cursor = value; + size_t token_length = strlen(token); + + while (*cursor) + { + while (*cursor == ',' || isspace((unsigned char)*cursor)) + cursor++; + + const char* end = strchr(cursor, ','); + if (!end) + end = cursor + strlen(cursor); + + const char* trimmed_end = end; + while (trimmed_end > cursor && isspace((unsigned char)trimmed_end[-1])) + trimmed_end--; + + const char* equals = memchr(cursor, '=', (size_t)(trimmed_end - cursor)); + if (equals) + trimmed_end = equals; + while (trimmed_end > cursor && isspace((unsigned char)trimmed_end[-1])) + trimmed_end--; + + if ((size_t)(trimmed_end - cursor) == token_length && strncasecmp(cursor, token, token_length) == 0) + return 1; + + cursor = *end ? end + 1 : end; + } + + return 0; +} + +static int vary_has_accept_encoding(const char* value) +{ + return value && (comma_token_contains(value, "*") || comma_token_contains(value, "Accept-Encoding")); +} + +static int compression_headers_possible(const chttpx_response_t* response) +{ + if (!response) + return 0; + + if (response_header_get(response, "Content-Encoding")) + return 0; + + int vary_index = response_header_index(response, "Vary"); + size_t required_slots = vary_index >= 0 ? 1 : 2; + if (response->headers_count + required_slots > MAX_HEADERS) + return 0; + + if (vary_index >= 0 && !vary_has_accept_encoding(response->headers[vary_index].value)) + { + size_t current = strlen(response->headers[vary_index].value); + size_t suffix = strlen(", Accept-Encoding"); + if (current + suffix >= MAX_HEADER_VALUE) + return 0; + } + + return 1; +} + +static int add_compression_headers(chttpx_response_t* response, const char* encoding) +{ + if (!response || !encoding || cHTTPX_HeaderAdd(response, "Content-Encoding", encoding) != 0) + return 0; + + int vary_index = response_header_index(response, "Vary"); + if (vary_index < 0) + { + if (cHTTPX_HeaderAdd(response, "Vary", "Accept-Encoding") != 0) + { + response->headers_count--; + return 0; + } + return 1; + } + + if (vary_has_accept_encoding(response->headers[vary_index].value)) + return 1; + + size_t length = strlen(response->headers[vary_index].value); + const char suffix[] = ", Accept-Encoding"; + if (length + sizeof(suffix) > sizeof(response->headers[vary_index].value)) + { + response->headers_count--; + return 0; + } + + memcpy(response->headers[vary_index].value + length, suffix, sizeof(suffix)); + return 1; +} + +static void trim_string(char** start, char** end) +{ + while (*start < *end && isspace((unsigned char)**start)) + (*start)++; + while (*end > *start && isspace((unsigned char)(*end)[-1])) + (*end)--; +} + +static double parse_quality_parameter(char* parameters) +{ + double quality = 1.0; + char* cursor = parameters; + + while (cursor && *cursor) + { + while (*cursor == ';' || isspace((unsigned char)*cursor)) + cursor++; + if (!*cursor) + break; + + char* end = strchr(cursor, ';'); + if (!end) + end = cursor + strlen(cursor); + + char* item_start = cursor; + char* item_end = end; + trim_string(&item_start, &item_end); + + char* equals = memchr(item_start, '=', (size_t)(item_end - item_start)); + if (equals) + { + char* name_start = item_start; + char* name_end = equals; + trim_string(&name_start, &name_end); + + if ((size_t)(name_end - name_start) == 1 && tolower((unsigned char)*name_start) == 'q') + { + char* value_start = equals + 1; + char* value_end = item_end; + trim_string(&value_start, &value_end); + + if (value_start == value_end) + return 0.0; + + char saved = *value_end; + *value_end = '\0'; + char* parsed_end = NULL; + double parsed = strtod(value_start, &parsed_end); + *value_end = saved; + + while (parsed_end && parsed_end < value_end && isspace((unsigned char)*parsed_end)) + parsed_end++; + + if (!parsed_end || parsed_end != value_end || parsed < 0.0 || parsed > 1.0) + return 0.0; + + quality = parsed; + } + } + + cursor = *end ? end + 1 : end; + } + + return quality; +} + +static void quality_from_header_value(const char* header, + const char* target, + double* exact_quality, + int* exact_found, + double* wildcard_quality, + int* wildcard_found) +{ + if (!header || !target || !exact_quality || !exact_found || !wildcard_quality || !wildcard_found) + return; + + const char* cursor = header; + + while (*cursor) + { + const char* comma = strchr(cursor, ','); + const char* item_end_const = comma ? comma : cursor + strlen(cursor); + size_t item_length = (size_t)(item_end_const - cursor); + + if (item_length > 0 && item_length < MAX_HEADER_VALUE) + { + char item[MAX_HEADER_VALUE]; + memcpy(item, cursor, item_length); + item[item_length] = '\0'; + + char* start = item; + char* end = item + item_length; + trim_string(&start, &end); + *end = '\0'; + + char* semicolon = strchr(start, ';'); + double quality = semicolon ? parse_quality_parameter(semicolon) : 1.0; + + char* token_end = semicolon ? semicolon : end; + trim_string(&start, &token_end); + char saved = *token_end; + *token_end = '\0'; + + if (strcasecmp(start, target) == 0) + { + if (!*exact_found || quality > *exact_quality) + *exact_quality = quality; + *exact_found = 1; + } + else if (strcmp(start, "*") == 0) + { + if (!*wildcard_found || quality > *wildcard_quality) + *wildcard_quality = quality; + *wildcard_found = 1; + } + + *token_end = saved; + } + + if (!comma) + break; + cursor = comma + 1; + } +} + +static double request_encoding_quality(const chttpx_request_t* request, const char* encoding, int identity, int* header_present) +{ + if (!request || !encoding) + return identity ? 1.0 : 0.0; + + double exact_quality = 0.0; + double wildcard_quality = 0.0; + int exact_found = 0; + int wildcard_found = 0; + int found_header = 0; + + for (size_t i = 0; i < request->headers_count; i++) + { + if (strcasecmp(request->headers[i].name, "Accept-Encoding") != 0) + continue; + + found_header = 1; + quality_from_header_value(request->headers[i].value, encoding, &exact_quality, &exact_found, &wildcard_quality, &wildcard_found); + } + + if (header_present) + *header_present = found_header; + + if (!found_header) + return identity ? 1.0 : 0.0; + + if (exact_found) + return exact_quality; + + if (identity) + return wildcard_found && wildcard_quality == 0.0 ? 0.0 : 1.0; + + return wildcard_found ? wildcard_quality : 0.0; +} + +static void make_empty_error(chttpx_response_t* response, int status) +{ + if (!response) + return; + + cHTTPX_ResponseCleanup(response); + response->status = status; + response->content_type = cHTTPX_CTYPE_TEXT; + response->headers_count = 0; + response->compression_disabled = true; +} + +static int response_semantics_allow_compression(const chttpx_request_t* request, const chttpx_response_t* response) +{ + if (!request || !response || response->compression_disabled || !response->body || response->body_size == 0) + return 0; + + if (strcasecmp(request->method ? request->method : "", "HEAD") == 0) + return 0; + + if (response->status < 200 || response->status == 204 || response->status == 205 || response->status == 206 || response->status == 304) + return 0; + + if (cHTTPX_HeaderGet((chttpx_request_t*)request, "Range") || response_header_get(response, "Content-Range")) + return 0; + + const char* cache_control = response_header_get(response, "Cache-Control"); + if (cache_control && comma_token_contains(cache_control, "no-transform")) + return 0; + + if (response_header_get(response, "Content-Encoding")) + return 0; + + return 1; +} + +static chttpx_middleware_result_t compression_middleware(chttpx_request_t* request, chttpx_response_t* response) +{ + chttpx_serv_t* server = request ? request->_server : NULL; + chttpx_compression_state_t* state = server ? (chttpx_compression_state_t*)server->compression_state : NULL; + if (!state || !response || !response_semantics_allow_compression(request, response)) + return next; + + int accept_encoding_present = 0; + double identity_quality = request_encoding_quality(request, "identity", 1, &accept_encoding_present); + if (!accept_encoding_present) + return next; + + const chttpx_compression_provider_t* selected = NULL; + double selected_quality = 0.0; + + for (size_t i = 0; i < state->config.providers_count; i++) + { + int ignored = 0; + double quality = request_encoding_quality(request, state->providers[i].encoding, 0, &ignored); + if (quality > selected_quality) + { + selected = &state->providers[i]; + selected_quality = quality; + } + } + + if (!selected || selected_quality <= 0.0) + { + if (identity_quality <= 0.0) + make_empty_error(response, cHTTPX_StatusNotAcceptable); + return next; + } + + if (selected_quality < identity_quality) + return next; + + if (response->body_size < state->config.min_size || !mime_is_eligible(state, response->content_type)) + { + if (identity_quality <= 0.0) + make_empty_error(response, cHTTPX_StatusNotAcceptable); + return next; + } + + if (!compression_headers_possible(response)) + { + if (identity_quality <= 0.0) + make_empty_error(response, cHTTPX_StatusInternalServerError); + return next; + } + + unsigned char* compressed = NULL; + size_t compressed_size = 0; + int result = selected->encode_buffer(response->body, response->body_size, state->config.level, &compressed, &compressed_size, selected->user_data); + if (result != CHTTPX_OK || !compressed || compressed_size == 0) + { + free(compressed); + compression_log(request, CHTTPX_LOG_WARN, "response compression provider failed; using identity when allowed"); + if (identity_quality <= 0.0) + make_empty_error(response, cHTTPX_StatusInternalServerError); + return next; + } + + if (compressed_size >= response->body_size && identity_quality >= selected_quality && identity_quality > 0.0) + { + free(compressed); + return next; + } + + size_t original_size = response->body_size; + if (!add_compression_headers(response, selected->encoding)) + { + free(compressed); + compression_log(request, CHTTPX_LOG_WARN, "response compression headers could not be added; using identity when allowed"); + if (identity_quality <= 0.0) + make_empty_error(response, cHTTPX_StatusInternalServerError); + return next; + } + + if (response->body_ownership == CHTTPX_BODY_OWNED) + free((void*)response->body); + + response->body = compressed; + response->body_size = compressed_size; + response->body_ownership = CHTTPX_BODY_OWNED; + + if (server->logger && server->log_level <= CHTTPX_LOG_DEBUG) + { + char message[192]; + snprintf(message, sizeof(message), "compressed response encoding=%s original=%zu compressed=%zu", + selected->encoding, original_size, compressed_size); + server->logger(CHTTPX_LOG_DEBUG, request->request_id, message, server->logger_data); + } + + return next; +} + +int cHTTPX_CompressionUse(chttpx_serv_t* server, const chttpx_compression_config_t* config) +{ + if (!server || !server->initialized) + return CHTTPX_ERR_INVALID_ARGUMENT; + + chttpx_compression_config_t defaults; + if (!config) + { + defaults = cHTTPX_CompressionDefault(); + config = &defaults; + } + + if (!compression_middleware_registered(server, compression_middleware) && + server->middleware.after_middleware_count >= MAX_MIDDLEWARES) + return CHTTPX_ERR_LIMIT; + + chttpx_compression_state_t* state = NULL; + int result = prepare_compression_state(config, &state); + if (result != CHTTPX_OK) + return result; + + chttpx_compression_state_t* old_state = (chttpx_compression_state_t*)server->compression_state; + server->compression_state = state; + free_compression_state(old_state); + + if (!compression_middleware_registered(server, compression_middleware)) + cHTTPX_MiddlewareUseAfter(server, compression_middleware); + + return CHTTPX_OK; +} + +int cHTTPX_RouteCompression(chttpx_route_t* route, bool enabled) +{ + if (!route) + return CHTTPX_ERR_INVALID_ARGUMENT; + + route->compression_disabled = !enabled; + return CHTTPX_OK; +} + +void cHTTPX_ResponseCompression(chttpx_response_t* response, bool enabled) +{ + if (response) + response->compression_disabled = !enabled; +} + +void _chttpx_compression_server_cleanup(chttpx_serv_t* server) +{ + if (!server) + return; + + free_compression_state((chttpx_compression_state_t*)server->compression_state); + server->compression_state = NULL; +} diff --git a/src/cHTTPX_compression.h b/src/cHTTPX_compression.h new file mode 100644 index 0000000..b90e274 --- /dev/null +++ b/src/cHTTPX_compression.h @@ -0,0 +1,96 @@ +/** + * Copyright (c) 2026 netcorelink + * + * Response compression middleware. + */ + +#ifndef COMPRESSION_H +#define COMPRESSION_H + +#ifdef __cplusplus +extern "C" +{ +#endif + +#include +#include + + struct chttpx_serv; + struct chttpx_route; + struct chttpx_response; + + /** + * Encode one complete buffered response body. + * + * The provider allocates *output with malloc-compatible ownership. The + * library takes ownership on success and frees it during response cleanup. + */ + typedef int (*chttpx_compression_encode_fn)(const unsigned char* input, + size_t input_size, + int level, + unsigned char** output, + size_t* output_size, + void* user_data); + + /** + * Compression provider descriptor. + * + * Providers are ordered by server preference when quality values are tied. + * encode_buffer is intentionally buffer-oriented; future streaming hooks + * can be added without changing response compression configuration. + */ + typedef struct + { + const char* encoding; + chttpx_compression_encode_fn encode_buffer; + void* user_data; + } chttpx_compression_provider_t; + + typedef struct + { + /* Do not compress bodies smaller than this many bytes. */ + size_t min_size; + + /* Provider-specific compression level. Built-in gzip accepts -1..9. */ + int level; + + /* + * MIME patterns eligible for compression. '*' is supported anywhere + * in a pattern. When the list is empty, every MIME type is eligible + * unless excluded below. + */ + const char** include_types; + size_t include_types_count; + + /* MIME patterns that must never be compressed. Exclusions win. */ + const char** exclude_types; + size_t exclude_types_count; + + /* + * Optional custom providers. When omitted, the built-in gzip provider + * is used. + */ + const chttpx_compression_provider_t* providers; + size_t providers_count; + } chttpx_compression_config_t; + + /** Return the default gzip-oriented compression configuration. */ + chttpx_compression_config_t cHTTPX_CompressionDefault(void); + + /** Enable response compression for a server. */ + int cHTTPX_CompressionUse(struct chttpx_serv* server, const chttpx_compression_config_t* config); + + /** Enable or disable compression for one route. */ + int cHTTPX_RouteCompression(struct chttpx_route* route, bool enabled); + + /** Enable or disable compression for one response. */ + void cHTTPX_ResponseCompression(struct chttpx_response* response, bool enabled); + + /* Internal per-server compression cleanup. */ + void _chttpx_compression_server_cleanup(struct chttpx_serv* server); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/src/cHTTPX_response.c b/src/cHTTPX_response.c index c4de684..3758e00 100644 --- a/src/cHTTPX_response.c +++ b/src/cHTTPX_response.c @@ -725,6 +725,9 @@ int _chttpx_dispatch(chttpx_serv_t* server, chttpx_request_t* req, chttpx_respon after_route_middlewares: for (size_t i = route->after_middleware_count; i > 0; i--) route->after_middlewares[i - 1](req, res); + + if (route->compression_disabled) + res->compression_disabled = true; } else { diff --git a/src/cHTTPX_response.h b/src/cHTTPX_response.h index d6e8882..0b59425 100644 --- a/src/cHTTPX_response.h +++ b/src/cHTTPX_response.h @@ -45,6 +45,9 @@ extern "C" chttpx_body_ownership_t body_ownership; + /* Set when response compression must be bypassed. */ + bool compression_disabled; + /* Times for logging */ struct timespec start_ts; struct timespec end_ts; diff --git a/src/cHTTPX_serv.c b/src/cHTTPX_serv.c index 4ba5540..5f3254d 100644 --- a/src/cHTTPX_serv.c +++ b/src/cHTTPX_serv.c @@ -15,6 +15,7 @@ #include "cHTTPX_middlewares.h" #include "cHTTPX_http.h" #include "cHTTPX_tls.h" +#include "cHTTPX_compression.h" #include @@ -625,6 +626,7 @@ void _chttpx_server_shutdown(chttpx_serv_t* server) server->routes_count = 0; server->routes_capacity = 0; + _chttpx_compression_server_cleanup(server); _chttpx_middleware_server_cleanup(server); for (size_t i = 0; i < server->cors.origins_count; i++) diff --git a/src/cHTTPX_serv.h b/src/cHTTPX_serv.h index 8a872c3..d0ffbe5 100644 --- a/src/cHTTPX_serv.h +++ b/src/cHTTPX_serv.h @@ -41,7 +41,8 @@ extern "C" CHTTPX_ERR_STATE = -10, CHTTPX_ERR_UNAVAILABLE = -11, CHTTPX_ERR_TIMEOUT = -12, - CHTTPX_ERR_TLS = -13 + CHTTPX_ERR_TLS = -13, + CHTTPX_ERR_COMPRESSION = -14 } chttpx_error_t; typedef enum @@ -107,7 +108,7 @@ extern "C" size_t allowed_types_count; } chttpx_upload_policy_t; - typedef struct + typedef struct chttpx_route { const char* method; const char* path; @@ -118,6 +119,7 @@ extern "C" size_t after_middleware_count; chttpx_upload_policy_t upload_policy; bool has_upload_policy; + bool compression_disabled; } chttpx_route_t; typedef struct chttpx_serv @@ -161,6 +163,7 @@ extern "C" chttpx_middleware_stack_t middleware; bool logging_enabled; void* rate_limiter_state; + void* compression_state; chttpx_cors_t cors; } chttpx_serv_t; diff --git a/tests/test_compression.c b/tests/test_compression.c new file mode 100644 index 0000000..16876fd --- /dev/null +++ b/tests/test_compression.c @@ -0,0 +1,339 @@ +#include "libchttpx.h" + +#include +#include +#include +#include + +#include + +#define RESPONSE_CAPACITY 65536 +#define LARGE_BODY_SIZE 4096 + +typedef struct +{ + unsigned char bytes[RESPONSE_CAPACITY]; + size_t size; + size_t header_size; + char headers[8192]; +} test_response_t; + +static unsigned char large_text[LARGE_BODY_SIZE]; +static unsigned char binary_body[LARGE_BODY_SIZE]; + +static void fill_bodies(void) +{ + for (size_t i = 0; i < LARGE_BODY_SIZE; i++) + { + large_text[i] = (unsigned char)('A' + (i % 4)); + binary_body[i] = (unsigned char)(i & 0xff); + } +} + +static void large_handler(chttpx_request_t* req, chttpx_response_t* res) +{ + (void)req; + *res = cHTTPX_ResBinary(cHTTPX_StatusOK, "text/plain; charset=utf-8", large_text, sizeof(large_text)); +} + +static void small_handler(chttpx_request_t* req, chttpx_response_t* res) +{ + (void)req; + *res = cHTTPX_ResMessage(cHTTPX_StatusOK, "small"); +} + +static void binary_handler(chttpx_request_t* req, chttpx_response_t* res) +{ + (void)req; + *res = cHTTPX_ResBinary(cHTTPX_StatusOK, "image/png", binary_body, sizeof(binary_body)); +} + +static void response_disabled_handler(chttpx_request_t* req, chttpx_response_t* res) +{ + (void)req; + *res = cHTTPX_ResBinary(cHTTPX_StatusOK, "text/plain", large_text, sizeof(large_text)); + cHTTPX_ResponseCompression(res, false); +} + +static void already_encoded_handler(chttpx_request_t* req, chttpx_response_t* res) +{ + (void)req; + *res = cHTTPX_ResBinary(cHTTPX_StatusOK, "text/plain", large_text, sizeof(large_text)); + assert(cHTTPX_HeaderAdd(res, "Content-Encoding", "br") == 0); +} + +static void no_transform_handler(chttpx_request_t* req, chttpx_response_t* res) +{ + (void)req; + *res = cHTTPX_ResBinary(cHTTPX_StatusOK, "text/plain", large_text, sizeof(large_text)); + assert(cHTTPX_HeaderAdd(res, "Cache-Control", "public, no-transform") == 0); +} + +static void wait_until_listening(chttpx_serv_t* server) +{ + for (int i = 0; i < 5000 && !__atomic_load_n(&server->listening, __ATOMIC_ACQUIRE); i++) + { +#ifdef CHTTPX_PLATFORM_WINDOWS + Sleep(1); +#else + usleep(1000); +#endif + } + + assert(__atomic_load_n(&server->listening, __ATOMIC_ACQUIRE)); +} + +static const unsigned char* find_bytes(const unsigned char* haystack, size_t haystack_size, const char* needle) +{ + size_t needle_size = strlen(needle); + if (!needle_size || needle_size > haystack_size) + return NULL; + + for (size_t i = 0; i + needle_size <= haystack_size; i++) + if (memcmp(haystack + i, needle, needle_size) == 0) + return haystack + i; + + return NULL; +} + +static test_response_t exchange(uint16_t port, const char* request) +{ + test_response_t response; + memset(&response, 0, sizeof(response)); + + chttpx_socket_t socket_fd = socket(AF_INET, SOCK_STREAM, 0); +#ifdef CHTTPX_PLATFORM_WINDOWS + assert(socket_fd != INVALID_SOCKET); +#else + assert(socket_fd >= 0); +#endif + + struct sockaddr_in address; + memset(&address, 0, sizeof(address)); + address.sin_family = AF_INET; + address.sin_port = htons(port); + address.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + + assert(connect(socket_fd, (struct sockaddr*)&address, sizeof(address)) == 0); + assert(cHTTPX_SendAll(socket_fd, request, strlen(request)) == CHTTPX_OK); + +#ifdef CHTTPX_PLATFORM_WINDOWS + shutdown(socket_fd, SD_SEND); +#else + shutdown(socket_fd, SHUT_WR); +#endif + + while (response.size < sizeof(response.bytes)) + { + int received = recv(socket_fd, response.bytes + response.size, sizeof(response.bytes) - response.size, 0); + if (received <= 0) + break; + response.size += (size_t)received; + } + + chttpx_close(socket_fd); + + const unsigned char* delimiter = find_bytes(response.bytes, response.size, "\r\n\r\n"); + assert(delimiter); + + response.header_size = (size_t)(delimiter - response.bytes) + 4; + assert(response.header_size < sizeof(response.headers)); + memcpy(response.headers, response.bytes, response.header_size); + response.headers[response.header_size] = '\0'; + return response; +} + +static size_t response_body_size(const test_response_t* response) +{ + assert(response && response->size >= response->header_size); + return response->size - response->header_size; +} + +static const unsigned char* response_body(const test_response_t* response) +{ + return response->bytes + response->header_size; +} + +static void assert_header(const test_response_t* response, const char* text) +{ + assert(strstr(response->headers, text) != NULL); +} + +static void assert_no_header(const test_response_t* response, const char* text) +{ + assert(strstr(response->headers, text) == NULL); +} + +static void assert_identity_large(const test_response_t* response) +{ + assert_header(response, "HTTP/1.1 200 OK"); + assert_no_header(response, "Content-Encoding:"); + assert(response_body_size(response) == sizeof(large_text)); + assert(memcmp(response_body(response), large_text, sizeof(large_text)) == 0); +} + +static void assert_gzip_large(const test_response_t* response) +{ + assert_header(response, "HTTP/1.1 200 OK"); + assert_header(response, "Content-Encoding: gzip"); + assert_header(response, "Vary: Accept-Encoding"); + assert(response_body_size(response) < sizeof(large_text)); + + unsigned char decompressed[LARGE_BODY_SIZE + 64]; + z_stream stream; + memset(&stream, 0, sizeof(stream)); + assert(inflateInit2(&stream, 15 + 16) == Z_OK); + + stream.next_in = (Bytef*)response_body(response); + stream.avail_in = (uInt)response_body_size(response); + stream.next_out = decompressed; + stream.avail_out = sizeof(decompressed); + + int result = inflate(&stream, Z_FINISH); + assert(result == Z_STREAM_END); + size_t decompressed_size = sizeof(decompressed) - stream.avail_out; + inflateEnd(&stream); + + assert(decompressed_size == sizeof(large_text)); + assert(memcmp(decompressed, large_text, sizeof(large_text)) == 0); +} + +static int failing_provider(const unsigned char* input, + size_t input_size, + int level, + unsigned char** output, + size_t* output_size, + void* user_data) +{ + (void)input; + (void)input_size; + (void)level; + (void)user_data; + *output = NULL; + *output_size = 0; + return CHTTPX_ERR_COMPRESSION; +} + +int main(void) +{ + fill_bodies(); + chttpx_app_t app; + assert(cHTTPX_AppInit(&app) == CHTTPX_OK); + + chttpx_config_t server_config = cHTTPX_DefaultConfig(); + server_config.port = 0; + + chttpx_serv_t* server = cHTTPX_AppServer(&app, "compression", &server_config); + assert(server); + + chttpx_compression_config_t compression = cHTTPX_CompressionDefault(); + compression.min_size = 128; + compression.level = 5; + assert(cHTTPX_CompressionUse(server, &compression) == CHTTPX_OK); + + chttpx_router_t router = cHTTPX_RoutePathPrefix(server, ""); + assert(cHTTPX_Get(&router, "/large", large_handler)); + assert(cHTTPX_Get(&router, "/small", small_handler)); + assert(cHTTPX_Get(&router, "/binary", binary_handler)); + assert(cHTTPX_Get(&router, "/response-disabled", response_disabled_handler)); + assert(cHTTPX_Get(&router, "/encoded", already_encoded_handler)); + assert(cHTTPX_Get(&router, "/no-transform", no_transform_handler)); + + chttpx_route_t* route_disabled = cHTTPX_Get(&router, "/route-disabled", large_handler); + assert(route_disabled); + assert(cHTTPX_RouteCompression(route_disabled, false) == CHTTPX_OK); + + assert(cHTTPX_AppStart(&app) == CHTTPX_OK); + wait_until_listening(server); + + test_response_t response = exchange( + server->port, + "GET /large HTTP/1.1\r\nHost: localhost\r\nAccept-Encoding: gzip\r\n\r\n"); + assert_gzip_large(&response); + + response = exchange( + server->port, + "GET /large HTTP/1.1\r\nHost: localhost\r\nAccept-Encoding: gzip;q=0.5, identity;q=1\r\n\r\n"); + assert_identity_large(&response); + + response = exchange( + server->port, + "GET /large HTTP/1.1\r\nHost: localhost\r\nAccept-Encoding: br;q=1, gzip;q=0.8, identity;q=0.2\r\n\r\n"); + assert_gzip_large(&response); + + response = exchange( + server->port, + "GET /large HTTP/1.1\r\nHost: localhost\r\nAccept-Encoding: GZIP ; q=1.0, identity;q=0\r\n\r\n"); + assert_gzip_large(&response); + + response = exchange( + server->port, + "GET /large HTTP/1.1\r\nHost: localhost\r\nAccept-Encoding: *;q=1, identity;q=0\r\n\r\n"); + assert_gzip_large(&response); + + response = exchange( + server->port, + "GET /large HTTP/1.1\r\nHost: localhost\r\nAccept-Encoding: gzip;q=0\r\n\r\n"); + assert_identity_large(&response); + + response = exchange( + server->port, + "GET /large HTTP/1.1\r\nHost: localhost\r\nAccept-Encoding: gzip;q=0, identity;q=0\r\n\r\n"); + assert_header(&response, "HTTP/1.1 406 Not Acceptable"); + assert(response_body_size(&response) == 0); + + response = exchange( + server->port, + "GET /small HTTP/1.1\r\nHost: localhost\r\nAccept-Encoding: gzip\r\n\r\n"); + assert_header(&response, "HTTP/1.1 200 OK"); + assert_no_header(&response, "Content-Encoding:"); + + response = exchange( + server->port, + "GET /binary HTTP/1.1\r\nHost: localhost\r\nAccept-Encoding: gzip\r\n\r\n"); + assert_header(&response, "HTTP/1.1 200 OK"); + assert_no_header(&response, "Content-Encoding:"); + assert(response_body_size(&response) == sizeof(binary_body)); + + response = exchange( + server->port, + "GET /response-disabled HTTP/1.1\r\nHost: localhost\r\nAccept-Encoding: gzip\r\n\r\n"); + assert_identity_large(&response); + + response = exchange( + server->port, + "GET /route-disabled HTTP/1.1\r\nHost: localhost\r\nAccept-Encoding: gzip\r\n\r\n"); + assert_identity_large(&response); + + response = exchange( + server->port, + "GET /encoded HTTP/1.1\r\nHost: localhost\r\nAccept-Encoding: gzip\r\n\r\n"); + assert_header(&response, "Content-Encoding: br"); + assert_no_header(&response, "Content-Encoding: gzip"); + + response = exchange( + server->port, + "GET /no-transform HTTP/1.1\r\nHost: localhost\r\nAccept-Encoding: gzip\r\n\r\n"); + assert_header(&response, "Cache-Control: public, no-transform"); + assert_no_header(&response, "Content-Encoding:"); + + chttpx_compression_provider_t failing = { + .encoding = "gzip", + .encode_buffer = failing_provider, + .user_data = NULL, + }; + chttpx_compression_config_t failing_config = cHTTPX_CompressionDefault(); + failing_config.min_size = 128; + failing_config.providers = &failing; + failing_config.providers_count = 1; + assert(cHTTPX_CompressionUse(server, &failing_config) == CHTTPX_OK); + + response = exchange( + server->port, + "GET /large HTTP/1.1\r\nHost: localhost\r\nAccept-Encoding: gzip;q=1, identity;q=0.5\r\n\r\n"); + assert_identity_large(&response); + + cHTTPX_AppShutdown(&app); + puts("compression tests passed"); + return 0; +}