Multi-platform http server (#646)

- Add server support to `http-module`
- Ensure `DevelopmentService` works on all platforms (including posix)
- Ensure the built-in web server with dashboard works on all platforms (inluding posix). It still has some issues with certain featuers, but the basics work.
This commit is contained in:
Ken Van Hoeylandt
2026-09-06 14:16:23 +02:00
committed by GitHub
parent a0b2ee7ebc
commit 1b16184a72
24 changed files with 1890 additions and 901 deletions
+156
View File
@@ -0,0 +1,156 @@
// SPDX-License-Identifier: Apache-2.0
#pragma once
#include <http/types.h>
#include <tactility/error.h>
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#include "types.h"
#ifdef __cplusplus
extern "C" {
#endif
/** Opaque per-request handle; a handler only ever holds a pointer to one of these. */
struct HttpServerRequest;
/** @return ERROR_NONE if the request was handled; any other value is only logged, the response
* (or its absence) is entirely up to the handler having already sent one via the
* http_server_request_send*() functions below. */
typedef error_t (*HttpServerHandlerFn)(struct HttpServerRequest* request, void* user_ctx);
/**
* One route: @a uri is matched against the request path (not the query string) together with
* @a method. A trailing wildcard character matches by prefix (e.g. "/fs/" + wildcard matches
* "/fs/list" and "/fs/x/y"); anything else must match exactly, same wildcard convention as
* ESP-IDF's httpd_uri_match_wildcard(). @a uri is caller-owned and must outlive the server, same
* contract as ESP-IDF's httpd_uri_t: a string literal is the usual case.
*/
struct HttpServerRequestHandler {
const char* uri;
enum HttpMethod method;
HttpServerHandlerFn callback;
void* user_ctx;
};
/** @a handlers is copied into the server at http_server_alloc() time; each handler's own `uri`
* pointer is not, so it must still outlive the server. */
struct HttpServerConfig {
uint16_t port;
/** Bind address, e.g. "0.0.0.0". Caller-owned; only read during http_server_alloc(). */
const char* address;
/** Stack size in bytes for the server's own task, where the platform backend needs one. */
uint32_t stack_size;
const struct HttpServerRequestHandler* handlers;
size_t handler_count;
};
struct HttpServer;
/**
* Allocates a server for @a config; does not start listening yet, see http_server_start().
* @return NULL on allocation failure
*/
struct HttpServer* http_server_alloc(const struct HttpServerConfig* config);
/** Stops the server if still running (see http_server_stop()) and frees it. */
void http_server_free(struct HttpServer* server);
/**
* Starts listening and serving requests.
* A request whose method+uri matches no registered handler gets a 404 response automatically.
* @retval ERROR_NONE on success, including if the server was already started
* @retval ERROR_RESOURCE the listening socket could not be created/bound
*/
error_t http_server_start(struct HttpServer* server);
/** Stops listening and blocks until any in-flight request has finished. Safe to call when not started. */
void http_server_stop(struct HttpServer* server);
bool http_server_is_started(struct HttpServer* server);
/** @return the bound port, e.g. to read back the OS-assigned port after starting with port 0. 0 if not started. */
uint16_t http_server_get_port(struct HttpServer* server);
// region Request
enum HttpMethod http_server_request_get_method(struct HttpServerRequest* request);
/**
* Copies the request's path (not including the query string, e.g. "/fs/list") into @a buffer.
* Useful from a handler registered against a wildcard route to see which concrete path matched.
* @return the path's actual length, same truncation convention as http_server_request_get_query().
*/
size_t http_server_request_get_uri(struct HttpServerRequest* request, char* buffer, size_t buffer_size);
/**
* Copies the request's raw query string (the part after '?', still URL-encoded, empty if none) into @a buffer.
* @return the query string's actual length, regardless of @a buffer_size. Same truncation
* convention as snprintf(): a return value >= @a buffer_size means the copy was truncated.
*/
size_t http_server_request_get_query(struct HttpServerRequest* request, char* buffer, size_t buffer_size);
/**
* Copies the named header's value into @a buffer, case-insensitively.
* @return the header value's actual length, same truncation convention as http_server_request_get_query(); 0 (with @a buffer left untouched) if the header is absent.
*/
size_t http_server_request_get_header(struct HttpServerRequest* request, const char* name, char* buffer, size_t buffer_size);
/** The request body's declared length (the "Content-Length" header), or 0 if absent. */
uint64_t http_server_request_get_content_length(struct HttpServerRequest* request);
/**
* Reads up to @a buffer_size currently-available body bytes. Blocking: waits for at least one byte, up to an internal per-call timeout.
* @return bytes read; 0 at end of body; negative on error or timeout
*/
int http_server_request_receive(struct HttpServerRequest* request, void* buffer, size_t buffer_size);
/** Must be called before the first http_server_request_send*() call on this request, if at all.
* Defaults to 200. Has no effect once a response has started sending. */
void http_server_request_set_status(struct HttpServerRequest* request, status_code_t status_code);
/** Same timing as http_server_request_set_status(); defaults to "text/plain". */
void http_server_request_set_content_type(struct HttpServerRequest* request, const char* content_type);
/** Same timing as http_server_request_set_status(): adds one arbitrary response header.
* e.g. "Location", "Content-Disposition".
* Both @a name and @a value are copied.
*/
void http_server_request_set_header(struct HttpServerRequest* request, const char* name, const char* value);
/**
* Sends the full response: status line, headers, then @a data as the entire body in one shot.
* Only the first call to any http_server_request_send*()/send_chunk_start() for a given request has any effect.
* @param[in] data may be NULL if @a length is 0
*/
error_t http_server_request_send(struct HttpServerRequest* request, const void* data, size_t length);
/** Same as http_server_request_send() with @a text's length and content type "text/plain". */
error_t http_server_request_send_string(struct HttpServerRequest* request, const char* text);
/** Sets @a status_code, then sends @a message as a plain-text body. */
error_t http_server_request_send_error(struct HttpServerRequest* request, int status_code, const char* message);
/**
* Starts a chunked response: sends the status line and headers (no Content-Length; chunked
* transfer instead) without a body yet. Follow with zero or more http_server_request_send_chunk() calls,
* then exactly one http_server_request_send_chunk_end(). Useful for streaming a file whose size you don't
* want to (or can't cheaply) compute up front. Only the first call to any
* http_server_request_send*()/send_chunk_start() for a given request has any effect.
*/
error_t http_server_request_send_chunk_start(struct HttpServerRequest* request);
/** Sends one chunk of a response started with http_server_request_send_chunk_start(). */
error_t http_server_request_send_chunk(struct HttpServerRequest* request, const void* data, size_t length);
/** Terminates a chunked response started with http_server_request_send_chunk_start(). */
error_t http_server_request_send_chunk_end(struct HttpServerRequest* request);
// endregion
#ifdef __cplusplus
}
#endif
+53
View File
@@ -0,0 +1,53 @@
// SPDX-License-Identifier: Apache-2.0
#pragma once
#include <tactility/error.h>
#include <stdint.h>
#ifdef __cplusplus
extern "C" {
#endif
// esp_http_client.h defines the same unscoped HTTP_METHOD_GET/POST/PUT/DELETE names.
// module.cpp avoids the clash by forward-declaring this enum instead of including this header.
// The fixed underlying type (C++ only) is what makes that forward declaration legal.
#ifdef __cplusplus
enum HttpMethod : int {
#else
enum HttpMethod {
#endif
HTTP_METHOD_CONNECT,
HTTP_METHOD_DELETE,
HTTP_METHOD_GET,
HTTP_METHOD_HEAD,
HTTP_METHOD_OPTIONS,
HTTP_METHOD_POST,
HTTP_METHOD_PATCH,
HTTP_METHOD_PUT,
HTTP_METHOD_TRACE,
};
/** An HTTP response status code, e.g. 200 or 404. */
typedef uint16_t status_code_t;
/** @return @a method's wire form, e.g. "GET" for HTTP_METHOD_GET. */
const char* http_method_to_string(enum HttpMethod method);
/**
* Parses @a text (e.g. the method token off a request line) into @a out_method.
* @retval ERROR_NONE on success
* @retval ERROR_NOT_FOUND @a text does not match any HttpMethod
*/
error_t http_method_from_string(const char* text, enum HttpMethod* out_method);
/**
* @warning Not all status codes are implemented, so check the return value
* @param[out] text @a code's standard reason phrase, e.g. "OK" for 200; only set on success
* @retval ERROR_NONE on success
* @retval ERROR_NOT_FOUND @a code has no known reason phrase
*/
error_t status_code_to_string(status_code_t code, const char** text);
#ifdef __cplusplus
}
#endif
+65 -2
View File
@@ -2,6 +2,11 @@
#include <http/download.h>
#include <http/module.h>
#include <tactility/error.h>
#include <stddef.h>
#include <stdint.h>
#ifdef ESP_PLATFORM
#include <sdkconfig.h>
#include <esp_http_client.h>
@@ -14,16 +19,74 @@
#endif
#endif
#include <sys/select.h>
extern "C" {
enum HttpMethod : int;
typedef uint16_t status_code_t;
struct HttpServerRequest;
struct HttpServer;
struct HttpServerConfig;
// Deliberately not #include <http/server.h> or <http/types.h>: their HttpMethod enum shares
// enumerator names with esp_http_client.h's own, so this file forward-declares exactly the
// functions it needs instead of including either header.
struct HttpServer* http_server_alloc(const struct HttpServerConfig* config);
void http_server_free(struct HttpServer* server);
error_t http_server_start(struct HttpServer* server);
void http_server_stop(struct HttpServer* server);
bool http_server_is_started(struct HttpServer* server);
uint16_t http_server_get_port(struct HttpServer* server);
enum HttpMethod http_server_request_get_method(struct HttpServerRequest* request);
size_t http_server_request_get_uri(struct HttpServerRequest* request, char* buffer, size_t buffer_size);
size_t http_server_request_get_query(struct HttpServerRequest* request, char* buffer, size_t buffer_size);
size_t http_server_request_get_header(struct HttpServerRequest* request, const char* name, char* buffer, size_t buffer_size);
uint64_t http_server_request_get_content_length(struct HttpServerRequest* request);
int http_server_request_receive(struct HttpServerRequest* request, void* buffer, size_t buffer_size);
void http_server_request_set_status(struct HttpServerRequest* request, status_code_t status_code);
void http_server_request_set_content_type(struct HttpServerRequest* request, const char* content_type);
void http_server_request_set_header(struct HttpServerRequest* request, const char* name, const char* value);
error_t http_server_request_send(struct HttpServerRequest* request, const void* data, size_t length);
error_t http_server_request_send_string(struct HttpServerRequest* request, const char* text);
error_t http_server_request_send_error(struct HttpServerRequest* request, int status_code, const char* message);
error_t http_server_request_send_chunk_start(struct HttpServerRequest* request);
error_t http_server_request_send_chunk(struct HttpServerRequest* request, const void* data, size_t length);
error_t http_server_request_send_chunk_end(struct HttpServerRequest* request);
const char* http_method_to_string(enum HttpMethod method);
error_t http_method_from_string(const char* text, enum HttpMethod* out_method);
error_t status_code_to_string(status_code_t code, const char** text);
static const ModuleSymbol SYMBOLS[] = {
DEFINE_MODULE_SYMBOL(http_download_subscribe),
DEFINE_MODULE_SYMBOL(http_download_unsubscribe),
DEFINE_MODULE_SYMBOL(http_download_poll),
DEFINE_MODULE_SYMBOL(http_download_start),
DEFINE_MODULE_SYMBOL(http_download_cancel),
DEFINE_MODULE_SYMBOL(http_server_alloc),
DEFINE_MODULE_SYMBOL(http_server_free),
DEFINE_MODULE_SYMBOL(http_server_start),
DEFINE_MODULE_SYMBOL(http_server_stop),
DEFINE_MODULE_SYMBOL(http_server_is_started),
DEFINE_MODULE_SYMBOL(http_server_get_port),
DEFINE_MODULE_SYMBOL(http_server_request_get_method),
DEFINE_MODULE_SYMBOL(http_server_request_get_query),
DEFINE_MODULE_SYMBOL(http_server_request_get_header),
DEFINE_MODULE_SYMBOL(http_server_request_get_content_length),
DEFINE_MODULE_SYMBOL(http_server_request_receive),
DEFINE_MODULE_SYMBOL(http_server_request_set_status),
DEFINE_MODULE_SYMBOL(http_server_request_set_content_type),
DEFINE_MODULE_SYMBOL(http_server_request_send),
DEFINE_MODULE_SYMBOL(http_server_request_send_string),
DEFINE_MODULE_SYMBOL(http_server_request_send_error),
DEFINE_MODULE_SYMBOL(http_server_request_get_uri),
DEFINE_MODULE_SYMBOL(http_server_request_set_header),
DEFINE_MODULE_SYMBOL(http_server_request_send_chunk_start),
DEFINE_MODULE_SYMBOL(http_server_request_send_chunk),
DEFINE_MODULE_SYMBOL(http_server_request_send_chunk_end),
// types
DEFINE_MODULE_SYMBOL(http_method_to_string),
DEFINE_MODULE_SYMBOL(http_method_from_string),
DEFINE_MODULE_SYMBOL(status_code_to_string),
// posix
DEFINE_MODULE_SYMBOL(select),
#ifdef ESP_PLATFORM
@@ -94,7 +157,7 @@ static const ModuleSymbol SYMBOLS[] = {
DEFINE_MODULE_SYMBOL(esp_http_client_get_url),
DEFINE_MODULE_SYMBOL(esp_http_client_get_chunk_length),
#endif
MODULE_SYMBOL_TERMINATOR
MODULE_SYMBOL_TERMINATOR,
};
Module http_module = {
+568
View File
@@ -0,0 +1,568 @@
// SPDX-License-Identifier: Apache-2.0
#include <http/server.h>
#include <tactility/concurrent/mutex.h>
#include <tactility/error.h>
#include <tactility/freertos/semphr.h>
#include <tactility/freertos/task.h>
#include <tactility/log.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <sys/select.h>
#include <sys/socket.h>
#include <unistd.h>
#include <cctype>
#include <cerrno>
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <new>
#include <string>
#include <utility>
#include <vector>
namespace {
constexpr auto* TAG = "http-server";
constexpr uint32_t DEFAULT_STACK_SIZE = 5120;
constexpr int LISTEN_BACKLOG = 8;
// How often the accept loop wakes to re-check stop_requested; not a per-request timeout.
constexpr int ACCEPT_POLL_TIMEOUT_MS = 200;
// Applied to every accepted connection's socket, for both header/body reads.
constexpr int CONNECTION_RECEIVE_TIMEOUT_MS = 5000;
// Total wall-clock budget for one connection's request line + headers, independent of the
// per-recv timeout above: a client trickling one byte at a time never trips that timeout but
// would otherwise stall the single-threaded accept loop (and http_server_stop()) indefinitely.
constexpr int CONNECTION_TOTAL_TIMEOUT_MS = 10000;
constexpr size_t MAX_LINE_LENGTH = 8192;
constexpr size_t MAX_HEADER_COUNT = 32;
// The FreeRTOS POSIX port's tick signal can interrupt a blocking syscall on the thread it targets
// (observed on the socket calls below), so every blocking recv()/send() here retries on EINTR
// rather than treating it as a real error or an orderly close.
ssize_t receive_retry(int socket_fd, void* buffer, size_t size) {
ssize_t result;
do {
result = recv(socket_fd, buffer, size, 0);
} while (result < 0 && errno == EINTR);
return result;
}
ssize_t send_retry(int socket_fd, const void* buffer, size_t size) {
size_t total_sent = 0;
while (total_sent < size) {
ssize_t sent = send(socket_fd, static_cast<const char*>(buffer) + total_sent, size - total_sent, 0);
if (sent < 0) {
if (errno == EINTR) {
continue;
}
return sent;
}
total_sent += static_cast<size_t>(sent);
}
return static_cast<ssize_t>(total_sent);
}
// Bounded, byte-at-a-time (same technique HttpdReq.cpp already uses for the ESP32 backend's own
// multipart parsing) since request lines/headers are short and this isn't a hot path.
bool read_line(int socket_fd, std::string& out_line, TickType_t deadline) {
out_line.clear();
char byte;
while (out_line.size() < MAX_LINE_LENGTH) {
if (xTaskGetTickCount() >= deadline) {
return false; // Connection exceeded its total budget.
}
ssize_t received = receive_retry(socket_fd, &byte, 1);
if (received <= 0) {
return false;
}
if (byte == '\n') {
if (!out_line.empty() && out_line.back() == '\r') {
out_line.pop_back();
}
return true;
}
out_line.push_back(byte);
}
return false; // Line exceeded MAX_LINE_LENGTH without a terminator.
}
} // namespace
struct HttpServerRequest {
int socket_fd;
HttpMethod method;
std::string path; // e.g. "/fs/list"; not including the query string
std::string query; // still URL-encoded, without the leading '?'
std::vector<std::pair<std::string, std::string>> headers;
uint64_t content_length = 0;
uint64_t body_remaining = 0;
status_code_t status_code = 200;
std::string content_type = "text/plain";
std::vector<std::pair<std::string, std::string>> extra_headers;
bool response_sent = false;
bool chunked = false;
};
struct HttpServer {
std::string address;
uint16_t configured_port = 0;
uint32_t stack_size = DEFAULT_STACK_SIZE;
std::vector<HttpServerRequestHandler> handlers;
Mutex mutex {};
int listen_fd = -1;
uint16_t bound_port = 0;
volatile bool stop_requested = false;
volatile bool running = false;
SemaphoreHandle_t stopped_semaphore = nullptr;
HttpServer() { mutex_construct(&mutex); }
~HttpServer() { mutex_destruct(&mutex); }
};
namespace {
// A caller-supplied header name/value/content-type reaching here unfiltered (e.g. a decoded
// upload filename) could otherwise inject extra header lines or split the response.
bool contains_crlf(const char* text) {
return strpbrk(text, "\r\n") != nullptr;
}
const std::pair<std::string, std::string>* find_header(const HttpServerRequest* request, const char* name) {
for (const auto& header : request->headers) {
if (strcasecmp(header.first.c_str(), name) == 0) {
return &header;
}
}
return nullptr;
}
// Status line, Content-Type, extra headers, then either Content-Length or (if @a chunked)
// Transfer-Encoding: chunked, terminated by the blank line that starts the body.
std::string build_response_prologue(HttpServerRequest* request, bool chunked, size_t content_length) {
const char* status_code_text;
if (status_code_to_string(request->status_code, &status_code_text) != ERROR_NONE) status_code_text = "Unknown";
std::string result = "HTTP/1.1 " + std::to_string(request->status_code) + " " + status_code_text + "\r\n";
result += "Content-Type: " + request->content_type + "\r\n";
if (chunked) {
result += "Transfer-Encoding: chunked\r\n";
} else {
result += "Content-Length: " + std::to_string(content_length) + "\r\n";
}
for (const auto& header : request->extra_headers) {
result += header.first + ": " + header.second + "\r\n";
}
result += "Connection: close\r\n\r\n";
return result;
}
// Reads the request line + headers off `client_fd`, dispatches to the matching handler (or a
// built-in 404/400), and guarantees a response is sent even if the handler didn't send one.
void handle_connection(HttpServer* server, int client_fd) {
timeval receive_timeout {
.tv_sec = CONNECTION_RECEIVE_TIMEOUT_MS / 1000,
.tv_usec = (CONNECTION_RECEIVE_TIMEOUT_MS % 1000) * 1000,
};
setsockopt(client_fd, SOL_SOCKET, SO_RCVTIMEO, &receive_timeout, sizeof(receive_timeout));
TickType_t deadline = xTaskGetTickCount() + pdMS_TO_TICKS(CONNECTION_TOTAL_TIMEOUT_MS);
std::string request_line;
if (!read_line(client_fd, request_line, deadline)) {
return; // Malformed/empty request, or the connection's total budget ran out.
}
size_t first_space = request_line.find(' ');
size_t second_space = request_line.find(' ', first_space == std::string::npos ? std::string::npos : first_space + 1);
if (first_space == std::string::npos || second_space == std::string::npos) {
return;
}
HttpServerRequest request {};
request.socket_fd = client_fd;
auto method_text = request_line.substr(0, first_space);
if (http_method_from_string(method_text.c_str(), &request.method) != ERROR_NONE) {
return;
}
std::string target = request_line.substr(first_space + 1, second_space - first_space - 1);
size_t query_start = target.find('?');
request.path = query_start == std::string::npos ? target : target.substr(0, query_start);
if (query_start != std::string::npos) {
request.query = target.substr(query_start + 1);
}
const std::string& path = request.path;
std::string header_line;
bool headers_complete = false;
while (read_line(client_fd, header_line, deadline)) {
if (header_line.empty()) {
headers_complete = true;
break;
}
if (request.headers.size() >= MAX_HEADER_COUNT) {
continue; // Cap reached: known callers only need the first handful (Content-Type etc).
}
size_t colon = header_line.find(':');
if (colon == std::string::npos) {
continue;
}
std::string name = header_line.substr(0, colon);
size_t value_start = header_line.find_first_not_of(' ', colon + 1);
std::string value = value_start == std::string::npos ? "" : header_line.substr(value_start);
request.headers.emplace_back(std::move(name), std::move(value));
}
if (!headers_complete) {
return; // Connection closed/timed out before the terminating blank line: never dispatch.
}
if (const auto* content_length_header = find_header(&request, "Content-Length")) {
const std::string& raw = content_length_header->second;
errno = 0;
char* end = nullptr;
unsigned long long parsed = strtoull(raw.c_str(), &end, 10);
bool all_digits = !raw.empty() && raw.find_first_not_of("0123456789") == std::string::npos;
if (!all_digits || errno == ERANGE || end != raw.c_str() + raw.size()) {
http_server_request_send_error(&request, 400, "Invalid Content-Length");
return;
}
request.content_length = parsed;
request.body_remaining = parsed;
}
const HttpServerRequestHandler* matched = nullptr;
for (const auto& handler : server->handlers) {
if (handler.method != request.method) {
continue;
}
size_t handler_uri_length = strlen(handler.uri);
bool is_wildcard = handler_uri_length > 0 && handler.uri[handler_uri_length - 1] == '*';
bool matches = is_wildcard
? path.compare(0, handler_uri_length - 1, handler.uri, handler_uri_length - 1) == 0
: path == handler.uri;
if (matches) {
matched = &handler;
break;
}
}
if (matched == nullptr) {
http_server_request_send_error(&request, 404, "Not Found");
return;
}
matched->callback(&request, matched->user_ctx);
if (!request.response_sent) {
LOG_W(TAG, "Handler for %s did not send a response", matched->uri);
http_server_request_send_error(&request, 500, "Handler did not send a response");
}
}
void server_task_main(void* raw_server) {
auto* server = static_cast<HttpServer*>(raw_server);
LOG_I(TAG, "Listening on port %u", static_cast<unsigned>(server->bound_port));
while (!server->stop_requested) {
fd_set read_fds;
FD_ZERO(&read_fds);
FD_SET(server->listen_fd, &read_fds);
timeval timeout {
.tv_sec = 0,
.tv_usec = ACCEPT_POLL_TIMEOUT_MS * 1000,
};
int ready = select(server->listen_fd + 1, &read_fds, nullptr, nullptr, &timeout);
if (ready <= 0) {
continue; // Timeout or interrupted: loop back around to re-check stop_requested.
}
int client_fd = accept(server->listen_fd, nullptr, nullptr);
if (client_fd < 0) {
continue;
}
handle_connection(server, client_fd);
close(client_fd);
}
xSemaphoreGive(server->stopped_semaphore);
vTaskDelete(nullptr);
}
} // namespace
extern "C" {
HttpServer* http_server_alloc(const HttpServerConfig* config) {
auto* server = new (std::nothrow) HttpServer();
if (server == nullptr) {
return nullptr;
}
server->address = config->address != nullptr ? config->address : "0.0.0.0";
server->configured_port = config->port;
server->stack_size = config->stack_size != 0 ? config->stack_size : DEFAULT_STACK_SIZE;
server->handlers.assign(config->handlers, config->handlers + config->handler_count);
return server;
}
void http_server_free(HttpServer* server) {
if (server == nullptr) {
return;
}
http_server_stop(server);
delete server;
}
error_t http_server_start(HttpServer* server) {
mutex_lock(&server->mutex);
if (server->running) {
mutex_unlock(&server->mutex);
return ERROR_NONE;
}
int fd = socket(AF_INET, SOCK_STREAM, 0);
if (fd < 0) {
mutex_unlock(&server->mutex);
return ERROR_RESOURCE;
}
int reuse = 1;
setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(reuse));
sockaddr_in address {};
address.sin_family = AF_INET;
address.sin_port = htons(server->configured_port);
if (inet_pton(AF_INET, server->address.c_str(), &address.sin_addr) != 1) {
address.sin_addr.s_addr = INADDR_ANY;
}
if (bind(fd, reinterpret_cast<sockaddr*>(&address), sizeof(address)) != 0 ||
listen(fd, LISTEN_BACKLOG) != 0) {
LOG_E(TAG, "Failed to bind/listen on port %u", static_cast<unsigned>(server->configured_port));
close(fd);
mutex_unlock(&server->mutex);
return ERROR_RESOURCE;
}
socklen_t address_length = sizeof(address);
getsockname(fd, reinterpret_cast<sockaddr*>(&address), &address_length);
server->listen_fd = fd;
server->bound_port = ntohs(address.sin_port);
server->stop_requested = false;
server->stopped_semaphore = xSemaphoreCreateBinary();
if (server->stopped_semaphore == nullptr) {
close(fd);
server->listen_fd = -1;
mutex_unlock(&server->mutex);
return ERROR_RESOURCE;
}
TaskHandle_t task_handle = nullptr;
if (xTaskCreate(server_task_main, "http-server", server->stack_size / sizeof(StackType_t), server, tskIDLE_PRIORITY + 1, &task_handle) != pdPASS) {
close(fd);
server->listen_fd = -1;
vSemaphoreDelete(server->stopped_semaphore);
server->stopped_semaphore = nullptr;
mutex_unlock(&server->mutex);
return ERROR_RESOURCE;
}
server->running = true;
mutex_unlock(&server->mutex);
return ERROR_NONE;
}
void http_server_stop(HttpServer* server) {
mutex_lock(&server->mutex);
if (!server->running) {
mutex_unlock(&server->mutex);
return;
}
server->stop_requested = true;
SemaphoreHandle_t semaphore = server->stopped_semaphore;
mutex_unlock(&server->mutex);
// Not held while waiting: the task itself never touches `mutex`, so this is only about not
// blocking a concurrent http_server_is_started()/get_port() call for the whole shutdown.
xSemaphoreTake(semaphore, portMAX_DELAY);
mutex_lock(&server->mutex);
close(server->listen_fd);
server->listen_fd = -1;
vSemaphoreDelete(server->stopped_semaphore);
server->stopped_semaphore = nullptr;
server->running = false;
mutex_unlock(&server->mutex);
}
bool http_server_is_started(HttpServer* server) {
mutex_lock(&server->mutex);
bool started = server->running;
mutex_unlock(&server->mutex);
return started;
}
uint16_t http_server_get_port(HttpServer* server) {
mutex_lock(&server->mutex);
uint16_t port = server->running ? server->bound_port : 0;
mutex_unlock(&server->mutex);
return port;
}
HttpMethod http_server_request_get_method(HttpServerRequest* request) {
return request->method;
}
size_t http_server_request_get_uri(HttpServerRequest* request, char* buffer, size_t buffer_size) {
if (buffer_size > 0) {
snprintf(buffer, buffer_size, "%s", request->path.c_str());
}
return request->path.size();
}
size_t http_server_request_get_query(HttpServerRequest* request, char* buffer, size_t buffer_size) {
if (buffer_size > 0) {
snprintf(buffer, buffer_size, "%s", request->query.c_str());
}
return request->query.size();
}
size_t http_server_request_get_header(HttpServerRequest* request, const char* name, char* buffer, size_t buffer_size) {
const auto* header = find_header(request, name);
if (header == nullptr) {
return 0;
}
if (buffer_size > 0) {
snprintf(buffer, buffer_size, "%s", header->second.c_str());
}
return header->second.size();
}
uint64_t http_server_request_get_content_length(HttpServerRequest* request) {
return request->content_length;
}
int http_server_request_receive(HttpServerRequest* request, void* buffer, size_t buffer_size) {
if (request->body_remaining == 0) {
return 0; // End of the declared body.
}
if (buffer_size > request->body_remaining) {
buffer_size = static_cast<size_t>(request->body_remaining);
}
ssize_t received = receive_retry(request->socket_fd, buffer, buffer_size);
if (received > 0) {
request->body_remaining -= static_cast<uint64_t>(received);
}
return static_cast<int>(received);
}
void http_server_request_set_status(HttpServerRequest* request, status_code_t status_code) {
if (!request->response_sent) {
request->status_code = status_code;
}
}
void http_server_request_set_content_type(HttpServerRequest* request, const char* content_type) {
if (request->response_sent) {
return;
}
if (contains_crlf(content_type)) {
LOG_W(TAG, "Rejected content type containing CR/LF");
return;
}
request->content_type = content_type;
}
void http_server_request_set_header(HttpServerRequest* request, const char* name, const char* value) {
if (request->response_sent) {
return;
}
if (contains_crlf(name) || contains_crlf(value)) {
LOG_W(TAG, "Rejected header containing CR/LF: %s", name);
return;
}
request->extra_headers.emplace_back(name, value);
}
error_t http_server_request_send(HttpServerRequest* request, const void* data, size_t length) {
if (request->response_sent) {
return ERROR_INVALID_STATE;
}
request->response_sent = true;
std::string prologue = build_response_prologue(request, false, length);
if (send_retry(request->socket_fd, prologue.data(), prologue.size()) < 0) {
return ERROR_RESOURCE;
}
if (length > 0) {
if (send_retry(request->socket_fd, data, length) < 0) {
return ERROR_RESOURCE;
}
}
return ERROR_NONE;
}
error_t http_server_request_send_string(HttpServerRequest* request, const char* text) {
return http_server_request_send(request, text, strlen(text));
}
error_t http_server_request_send_error(HttpServerRequest* request, int status_code, const char* message) {
http_server_request_set_status(request, status_code);
return http_server_request_send_string(request, message);
}
error_t http_server_request_send_chunk_start(HttpServerRequest* request) {
if (request->response_sent) {
return ERROR_INVALID_STATE;
}
request->response_sent = true;
request->chunked = true;
std::string prologue = build_response_prologue(request, true, 0);
if (send_retry(request->socket_fd, prologue.data(), prologue.size()) < 0) {
return ERROR_RESOURCE;
}
return ERROR_NONE;
}
error_t http_server_request_send_chunk(HttpServerRequest* request, const void* data, size_t length) {
if (!request->chunked) {
return ERROR_INVALID_STATE;
}
if (length == 0) {
return ERROR_NONE; // A zero-length chunk is indistinguishable from the terminator.
}
char size_line[32];
int size_line_length = snprintf(size_line, sizeof(size_line), "%zx\r\n", length);
if (send_retry(request->socket_fd, size_line, static_cast<size_t>(size_line_length)) < 0) {
return ERROR_RESOURCE;
}
if (send_retry(request->socket_fd, data, length) < 0) {
return ERROR_RESOURCE;
}
if (send_retry(request->socket_fd, "\r\n", 2) < 0) {
return ERROR_RESOURCE;
}
return ERROR_NONE;
}
error_t http_server_request_send_chunk_end(HttpServerRequest* request) {
if (!request->chunked) {
return ERROR_INVALID_STATE;
}
request->chunked = false; // A second call now no-ops instead of re-sending the terminator.
if (send_retry(request->socket_fd, "0\r\n\r\n", 5) < 0) {
return ERROR_RESOURCE;
}
return ERROR_NONE;
}
} // extern "C"
+59
View File
@@ -0,0 +1,59 @@
#include <http/types.h>
#include <tactility/error.h>
#include <cstring>
extern "C" {
const char* http_method_to_string(HttpMethod method) {
switch (method) {
case HTTP_METHOD_CONNECT:
return "CONNECT";
case HTTP_METHOD_DELETE:
return "DELETE";
case HTTP_METHOD_GET:
return "GET";
case HTTP_METHOD_HEAD:
return "HEAD";
case HTTP_METHOD_OPTIONS:
return "OPTIONS";
case HTTP_METHOD_POST:
return "POST";
case HTTP_METHOD_PATCH:
return "PATCH";
case HTTP_METHOD_PUT:
return "PUT";
case HTTP_METHOD_TRACE:
return "TRACE";
}
return "UNKNOWN";
}
error_t http_method_from_string(const char* text, HttpMethod* out_method) {
if (strcmp("CONNECT", text) == 0) { *out_method = HTTP_METHOD_CONNECT; return ERROR_NONE; }
if (strcmp("DELETE", text) == 0) { *out_method = HTTP_METHOD_DELETE; return ERROR_NONE; }
if (strcmp("GET", text) == 0) { *out_method = HTTP_METHOD_GET; return ERROR_NONE; }
if (strcmp("HEAD", text) == 0) { *out_method = HTTP_METHOD_HEAD; return ERROR_NONE; }
if (strcmp("OPTIONS", text) == 0) { *out_method = HTTP_METHOD_OPTIONS; return ERROR_NONE; }
if (strcmp("POST", text) == 0) { *out_method = HTTP_METHOD_POST; return ERROR_NONE; }
if (strcmp("PATCH", text) == 0) { *out_method = HTTP_METHOD_PATCH; return ERROR_NONE; }
if (strcmp("PUT", text) == 0) { *out_method = HTTP_METHOD_PUT; return ERROR_NONE; }
if (strcmp("TRACE", text) == 0) { *out_method = HTTP_METHOD_TRACE; return ERROR_NONE; }
return ERROR_NOT_FOUND;
}
error_t status_code_to_string(status_code_t code, const char** text) {
switch (code) {
case 200: *text = "OK"; return ERROR_NONE;
case 302: *text = "Found"; return ERROR_NONE;
case 400: *text = "Bad Request"; return ERROR_NONE;
case 401: *text = "Unauthorized"; return ERROR_NONE;
case 403: *text = "Forbidden"; return ERROR_NONE;
case 404: *text = "Not Found"; return ERROR_NONE;
case 405: *text = "Method Not Allowed"; return ERROR_NONE;
case 500: *text = "Internal Server Error"; return ERROR_NONE;
case 501: *text = "Method Not Implemented"; return ERROR_NONE;
default: return ERROR_NOT_FOUND;
}
}
}
@@ -0,0 +1,252 @@
#include "doctest.h"
#include <http/server.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <sys/socket.h>
#include <unistd.h>
#include <cerrno>
#include <cstring>
#include <string>
namespace {
// Connects to 127.0.0.1:port, sends `request` verbatim, and returns whatever the server sent
// back before closing the connection.
std::string send_request(uint16_t port, const std::string& request) {
int fd = socket(AF_INET, SOCK_STREAM, 0);
REQUIRE(fd >= 0);
struct sockaddr_in address {};
address.sin_family = AF_INET;
address.sin_port = htons(port);
inet_pton(AF_INET, "127.0.0.1", &address.sin_addr);
// The FreeRTOS POSIX port's tick signal can interrupt a blocking syscall on this thread, so
// every blocking call below retries on EINTR (matches server_posix.cpp's own recv_retry()).
int connect_result;
do {
connect_result = connect(fd, reinterpret_cast<struct sockaddr*>(&address), sizeof(address));
} while (connect_result != 0 && errno == EINTR);
REQUIRE(connect_result == 0);
// Without this, a server bug that leaves the connection open makes this block in recv()
// until CTest's/CI's external job timeout, instead of failing this test.
struct timeval receive_timeout { .tv_sec = 5, .tv_usec = 0 };
setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &receive_timeout, sizeof(receive_timeout));
size_t total_sent = 0;
while (total_sent < request.size()) {
ssize_t sent = send(fd, request.data() + total_sent, request.size() - total_sent, 0);
if (sent < 0) {
REQUIRE(errno == EINTR);
continue;
}
total_sent += static_cast<size_t>(sent);
}
std::string response;
char chunk[256];
ssize_t received;
while (true) {
received = recv(fd, chunk, sizeof(chunk), 0);
if (received < 0 && errno == EINTR) {
continue;
}
if (received <= 0) {
break;
}
response.append(chunk, static_cast<size_t>(received));
}
close(fd);
return response;
}
error_t handle_ping(struct HttpServerRequest* request, void*) {
return http_server_request_send_string(request, "pong");
}
error_t handle_wildcard(struct HttpServerRequest* request, void*) {
char uri[64] {};
http_server_request_get_uri(request, uri, sizeof(uri));
return http_server_request_send_string(request, uri);
}
error_t handle_redirect(struct HttpServerRequest* request, void*) {
http_server_request_set_status(request, 302);
http_server_request_set_header(request, "Location", "/elsewhere");
return http_server_request_send(request, nullptr, 0);
}
error_t handle_chunked(struct HttpServerRequest* request, void*) {
if (http_server_request_send_chunk_start(request) != ERROR_NONE) {
return ERROR_UNDEFINED;
}
http_server_request_send_chunk(request, "one-", 4);
http_server_request_send_chunk(request, "two", 3);
http_server_request_send_chunk_end(request);
return ERROR_NONE;
}
error_t handle_echo(struct HttpServerRequest* request, void*) {
char query[64] {};
http_server_request_get_query(request, query, sizeof(query));
char body[64] {};
size_t total_read = 0;
uint64_t content_length = http_server_request_get_content_length(request);
while (total_read < content_length && total_read < sizeof(body) - 1) {
int read = http_server_request_receive(request, body + total_read, sizeof(body) - 1 - total_read);
if (read <= 0) {
break;
}
total_read += static_cast<size_t>(read);
}
std::string response = std::string("query=") + query + " body=" + body;
return http_server_request_send_string(request, response.c_str());
}
} // namespace
TEST_CASE("http_server_start binds an OS-assigned port and serves a registered handler") {
HttpServerRequestHandler handlers[] = {
{ .uri = "/ping", .method = HTTP_METHOD_GET, .callback = handle_ping, .user_ctx = nullptr },
};
HttpServerConfig config { .port = 0, .address = "0.0.0.0", .stack_size = 0, .handlers = handlers, .handler_count = 1 };
HttpServer* server = http_server_alloc(&config);
REQUIRE(server != nullptr);
CHECK_EQ(http_server_start(server), ERROR_NONE);
CHECK(http_server_is_started(server));
uint16_t port = http_server_get_port(server);
CHECK_NE(port, 0);
std::string response = send_request(port, "GET /ping HTTP/1.1\r\nHost: x\r\n\r\n");
CHECK_NE(response.find("200"), std::string::npos);
CHECK_NE(response.find("pong"), std::string::npos);
http_server_free(server);
}
TEST_CASE("http_server_start serves query string and request body to the handler") {
HttpServerRequestHandler handlers[] = {
{ .uri = "/echo", .method = HTTP_METHOD_PUT, .callback = handle_echo, .user_ctx = nullptr },
};
HttpServerConfig config { .port = 0, .address = "0.0.0.0", .stack_size = 0, .handlers = handlers, .handler_count = 1 };
HttpServer* server = http_server_alloc(&config);
REQUIRE(server != nullptr);
REQUIRE_EQ(http_server_start(server), ERROR_NONE);
uint16_t port = http_server_get_port(server);
std::string body = "hello";
std::string request = "PUT /echo?name=world HTTP/1.1\r\nHost: x\r\nContent-Length: " + std::to_string(body.size()) + "\r\n\r\n" + body;
std::string response = send_request(port, request);
CHECK_NE(response.find("query=name=world"), std::string::npos);
CHECK_NE(response.find("body=hello"), std::string::npos);
http_server_free(server);
}
TEST_CASE("an unmatched uri gets a 404") {
HttpServerRequestHandler handlers[] = {
{ .uri = "/ping", .method = HTTP_METHOD_GET, .callback = handle_ping, .user_ctx = nullptr },
};
HttpServerConfig config { .port = 0, .address = "0.0.0.0", .stack_size = 0, .handlers = handlers, .handler_count = 1 };
HttpServer* server = http_server_alloc(&config);
REQUIRE(server != nullptr);
REQUIRE_EQ(http_server_start(server), ERROR_NONE);
uint16_t port = http_server_get_port(server);
std::string response = send_request(port, "GET /missing HTTP/1.1\r\nHost: x\r\n\r\n");
CHECK_NE(response.find("404"), std::string::npos);
http_server_free(server);
}
TEST_CASE("a trailing '*' route matches by prefix and get_uri returns the matched path") {
HttpServerRequestHandler handlers[] = {
{ .uri = "/fs/*", .method = HTTP_METHOD_GET, .callback = handle_wildcard, .user_ctx = nullptr },
};
HttpServerConfig config { .port = 0, .address = "0.0.0.0", .stack_size = 0, .handlers = handlers, .handler_count = 1 };
HttpServer* server = http_server_alloc(&config);
REQUIRE(server != nullptr);
REQUIRE_EQ(http_server_start(server), ERROR_NONE);
uint16_t port = http_server_get_port(server);
std::string response = send_request(port, "GET /fs/list?path=/data HTTP/1.1\r\nHost: x\r\n\r\n");
CHECK_NE(response.find("200"), std::string::npos);
CHECK_NE(response.find("/fs/list"), std::string::npos);
// get_uri() must not include the query string.
CHECK_EQ(response.find("path=/data"), std::string::npos);
http_server_free(server);
}
TEST_CASE("set_status and set_header apply to the response") {
HttpServerRequestHandler handlers[] = {
{ .uri = "/redirect", .method = HTTP_METHOD_GET, .callback = handle_redirect, .user_ctx = nullptr },
};
HttpServerConfig config { .port = 0, .address = "0.0.0.0", .stack_size = 0, .handlers = handlers, .handler_count = 1 };
HttpServer* server = http_server_alloc(&config);
REQUIRE(server != nullptr);
REQUIRE_EQ(http_server_start(server), ERROR_NONE);
uint16_t port = http_server_get_port(server);
std::string response = send_request(port, "GET /redirect HTTP/1.1\r\nHost: x\r\n\r\n");
CHECK_NE(response.find("302"), std::string::npos);
CHECK_NE(response.find("Location: /elsewhere"), std::string::npos);
http_server_free(server);
}
TEST_CASE("a chunked response delivers all chunks concatenated") {
HttpServerRequestHandler handlers[] = {
{ .uri = "/chunked", .method = HTTP_METHOD_GET, .callback = handle_chunked, .user_ctx = nullptr },
};
HttpServerConfig config { .port = 0, .address = "0.0.0.0", .stack_size = 0, .handlers = handlers, .handler_count = 1 };
HttpServer* server = http_server_alloc(&config);
REQUIRE(server != nullptr);
REQUIRE_EQ(http_server_start(server), ERROR_NONE);
uint16_t port = http_server_get_port(server);
std::string response = send_request(port, "GET /chunked HTTP/1.1\r\nHost: x\r\n\r\n");
CHECK_NE(response.find("Transfer-Encoding: chunked"), std::string::npos);
// Wire format is "<hex-len>\r\n<data>\r\n" per chunk, terminated by "0\r\n\r\n". The two
// chunks are not contiguous in the raw response, so check each piece and the framing.
CHECK_NE(response.find("4\r\none-\r\n"), std::string::npos);
CHECK_NE(response.find("3\r\ntwo\r\n"), std::string::npos);
CHECK(response.ends_with("0\r\n\r\n"));
http_server_free(server);
}
TEST_CASE("http_server_stop closes the listening port") {
HttpServerConfig config { .port = 0, .address = "0.0.0.0", .stack_size = 0, .handlers = nullptr, .handler_count = 0 };
HttpServer* server = http_server_alloc(&config);
REQUIRE(server != nullptr);
REQUIRE_EQ(http_server_start(server), ERROR_NONE);
uint16_t port = http_server_get_port(server);
http_server_stop(server);
CHECK_FALSE(http_server_is_started(server));
int fd = socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in address {};
address.sin_family = AF_INET;
address.sin_port = htons(port);
inet_pton(AF_INET, "127.0.0.1", &address.sin_addr);
CHECK_NE(connect(fd, reinterpret_cast<struct sockaddr*>(&address), sizeof(address)), 0);
close(fd);
http_server_free(server);
}