feat: implement the production HTTP API

This commit is contained in:
2026-08-28 23:20:07 +03:00
parent 97bebfd8fb
commit 95b3450317
12 changed files with 872 additions and 546 deletions
+371
View File
@@ -0,0 +1,371 @@
#include "http_api.h"
#include <inttypes.h>
#include <stdarg.h>
#include <stdio.h>
#include <string.h>
#include "state_presenter.h"
enum { kErrorMessageBytes = 80, kErrorResponseBytes = 160 };
typedef struct { const char *text; size_t length; } json_reader_t;
static void skip_space(json_reader_t *reader) {
while (reader->length > 0U && (*reader->text == ' ' || *reader->text == '\n' ||
*reader->text == '\r' || *reader->text == '\t')) {
++reader->text;
--reader->length;
}
}
static bool take_character(json_reader_t *reader, char expected) {
skip_space(reader);
if (reader->length == 0U || *reader->text != expected) return false;
++reader->text;
--reader->length;
return true;
}
static bool parse_string(json_reader_t *reader, char *output, size_t output_size) {
if (!take_character(reader, '"') || output_size == 0U) return false;
size_t written = 0;
while (reader->length > 0U && *reader->text != '"') {
unsigned char character = (unsigned char)*reader->text++;
--reader->length;
if (character < 0x20U) return false;
if (character == '\\') {
if (reader->length == 0U) return false;
const char escaped = *reader->text++;
--reader->length;
if (escaped == '"' || escaped == '\\' || escaped == '/') character = (unsigned char)escaped;
else if (escaped == 'b') character = '\b';
else if (escaped == 'f') character = '\f';
else if (escaped == 'n') character = '\n';
else if (escaped == 'r') character = '\r';
else if (escaped == 't') character = '\t';
else return false;
}
if (written + 1U >= output_size) return false;
output[written++] = (char)character;
}
if (reader->length == 0U) return false;
++reader->text;
--reader->length;
output[written] = '\0';
return true;
}
static bool parse_u32(json_reader_t *reader, uint32_t *value) {
skip_space(reader);
if (reader->length == 0U || *reader->text < '0' || *reader->text > '9') return false;
uint32_t parsed = 0;
do {
const uint8_t digit = (uint8_t)(*reader->text - '0');
if (parsed > (UINT32_MAX - digit) / 10U) return false;
parsed = parsed * 10U + digit;
++reader->text;
--reader->length;
} while (reader->length > 0U && *reader->text >= '0' && *reader->text <= '9');
*value = parsed;
return true;
}
static bool parse_object_start(json_reader_t *reader) { return take_character(reader, '{'); }
static bool parse_next_key(json_reader_t *reader, bool *first, char *key, size_t key_size) {
skip_space(reader);
if (!*first && !take_character(reader, ',')) return false;
*first = false;
if (!parse_string(reader, key, key_size)) return false;
return take_character(reader, ':');
}
static bool parse_object_end(json_reader_t *reader) {
if (!take_character(reader, '}')) return false;
skip_space(reader);
return reader->length == 0U;
}
static bool parse_token(const char *text, uint8_t token[kSessionTokenBytes]) {
if (text == NULL || strlen(text) != kSessionTokenBytes * 2U) return false;
for (uint8_t index = 0; index < kSessionTokenBytes; ++index) {
const char high = text[index * 2U];
const char low = text[index * 2U + 1U];
if (high < '0' || (high > '9' && high < 'a') || high > 'f' ||
low < '0' || (low > '9' && low < 'a') || low > 'f') return false;
const uint8_t high_value = (uint8_t)(high <= '9' ? high - '0' : high - 'a' + 10);
const uint8_t low_value = (uint8_t)(low <= '9' ? low - '0' : low - 'a' + 10);
token[index] = (uint8_t)((high_value << 4U) | low_value);
}
return true;
}
static void token_text(const uint8_t token[kSessionTokenBytes], char output[kSessionTokenBytes * 2U + 1U]) {
static const char hex[] = "0123456789abcdef";
for (uint8_t index = 0; index < kSessionTokenBytes; ++index) {
output[index * 2U] = hex[token[index] >> 4U];
output[index * 2U + 1U] = hex[token[index] & 0x0fU];
}
output[kSessionTokenBytes * 2U] = '\0';
}
static const char *role_text(role_t role) {
return role == ROLE_PLAYER_1 ? "player1" : role == ROLE_PLAYER_2 ? "player2" : "spectator";
}
static const char *phase_text(phase_t phase) {
static const char *const values[] = {"lobby", "preparing", "in_progress", "finished", "rematch_wait"};
return phase <= PHASE_REMATCH_WAIT ? values[phase] : "lobby";
}
static void response_write(http_api_response_t *response, uint16_t status, const char *format, ...) {
va_list arguments;
va_start(arguments, format);
const int length = vsnprintf(response->body, sizeof(response->body), format, arguments);
va_end(arguments);
response->status = length >= 0 && (size_t)length < sizeof(response->body) ? status : 500U;
response->body_length = response->status == status ? (size_t)length : 0U;
if (response->status != status) response->body[0] = '\0';
}
static void response_error(http_api_response_t *response, uint16_t status, const char *code, const char *message,
uint32_t version) {
response_write(response, status, "{\"ok\":false,\"code\":\"%s\",\"message\":\"%s\",\"version\":%" PRIu32 "}",
code, message, version);
if (response->body_length >= kErrorResponseBytes) response_write(response, 500U, "{\"ok\":false,\"code\":\"SERVER_BUSY\",\"message\":\"Ошибка сервера\",\"version\":0}");
}
static void response_lifecycle_error(http_api_response_t *response, lifecycle_result_t result, uint32_t version) {
switch (result) {
case LIFECYCLE_RESULT_INVALID_NAME: response_error(response, 400U, "INVALID_NAME", "Некорректное имя", version); break;
case LIFECYCLE_RESULT_INVALID_ROLE: response_error(response, 400U, "INVALID_ROLE", "Некорректная роль", version); break;
case LIFECYCLE_RESULT_INVALID_MODE: response_error(response, 400U, "INVALID_MODE", "Некорректный режим", version); break;
case LIFECYCLE_RESULT_INVALID_COORDINATE: response_error(response, 400U, "INVALID_COORDINATE", "Некорректные координаты", version); break;
case LIFECYCLE_RESULT_NO_PLAYER_SLOT: response_error(response, 409U, "NO_PLAYER_SLOT", "Нет места игрока", version); break;
case LIFECYCLE_RESULT_NO_SPECTATOR_SLOT: response_error(response, 409U, "NO_SPECTATOR_SLOT", "Нет места зрителя", version); break;
case LIFECYCLE_RESULT_FORBIDDEN_ROLE: response_error(response, 403U, "FORBIDDEN_ROLE", "Роль не может выполнить действие", version); break;
case LIFECYCLE_RESULT_WRONG_PHASE: response_error(response, 409U, "WRONG_PHASE", "Действие недоступно сейчас", version); break;
case LIFECYCLE_RESULT_NOT_YOUR_TURN: response_error(response, 409U, "NOT_YOUR_TURN", "Сейчас ход соперника", version); break;
case LIFECYCLE_RESULT_CELL_ALREADY_SHOT: response_error(response, 409U, "CELL_ALREADY_SHOT", "Клетка уже обстреляна", version); break;
case LIFECYCLE_RESULT_STALE_GAME: response_error(response, 409U, "STALE_GAME", "Партия уже изменилась", version); break;
case LIFECYCLE_RESULT_UNAUTHORIZED: response_error(response, 401U, "UNAUTHORIZED", "Сессия не найдена", version); break;
default: response_error(response, 503U, "SERVER_BUSY", "Сервер занят", version); break;
}
}
static bool expect_post_body(const http_api_request_t *request, size_t maximum, http_api_response_t *response,
uint32_t version) {
if (request->method != HTTP_API_POST || !request->content_type_json) {
response_error(response, 400U, "MALFORMED_JSON", "Ожидается JSON запрос", version);
return false;
}
if (request->body == NULL || request->body_length > maximum) {
response_error(response, 413U, "PAYLOAD_TOO_LARGE", "Слишком большой запрос", version);
return false;
}
return true;
}
static bool parse_join(const http_api_request_t *request, char name[kDisplayNameBytes + 1U], role_t *role) {
json_reader_t reader = {.text = request->body, .length = request->body_length};
char key[16];
char role_value[16];
bool first = true;
uint8_t fields = 0;
if (!parse_object_start(&reader)) return false;
while (reader.length > 0U && *reader.text != '}') {
if (!parse_next_key(&reader, &first, key, sizeof(key))) return false;
if (strcmp(key, "name") == 0 && (fields & 1U) == 0U) {
if (!parse_string(&reader, name, kDisplayNameBytes + 1U)) return false;
fields |= 1U;
} else if (strcmp(key, "requestedRole") == 0 && (fields & 2U) == 0U) {
if (!parse_string(&reader, role_value, sizeof(role_value))) return false;
fields |= 2U;
} else return false;
}
if (!parse_object_end(&reader) || fields != 3U) return false;
if (strcmp(role_value, "player1") == 0) *role = ROLE_PLAYER_1;
else if (strcmp(role_value, "player2") == 0) *role = ROLE_PLAYER_2;
else if (strcmp(role_value, "spectator") == 0) *role = ROLE_SPECTATOR;
else *role = (role_t)UINT8_MAX;
return true;
}
static bool parse_token_only(const http_api_request_t *request, char token[kSessionTokenBytes * 2U + 1U]) {
json_reader_t reader = {.text = request->body, .length = request->body_length};
char key[16];
bool first = true;
if (!parse_object_start(&reader) || !parse_next_key(&reader, &first, key, sizeof(key)) || strcmp(key, "token") != 0 ||
!parse_string(&reader, token, kSessionTokenBytes * 2U + 1U)) return false;
return parse_object_end(&reader);
}
static bool parse_command_with_token(const http_api_request_t *request, app_command_t *command, bool needs_mode,
bool needs_coordinate, uint8_t token[kSessionTokenBytes]) {
char body_token[kSessionTokenBytes * 2U + 1U] = {0};
/* Parse into a temporary command first; token bytes never occupy command storage. */
json_reader_t reader = {.text = request->body, .length = request->body_length};
char key[16];
char mode[8] = {0};
bool first = true;
uint8_t fields = 0;
uint32_t x = 0;
uint32_t y = 0;
if (!parse_object_start(&reader)) return false;
while (reader.length > 0U && *reader.text != '}') {
if (!parse_next_key(&reader, &first, key, sizeof(key))) return false;
if (strcmp(key, "token") == 0 && (fields & 1U) == 0U) {
if (!parse_string(&reader, body_token, sizeof(body_token))) return false;
fields |= 1U;
} else if (strcmp(key, "gameId") == 0 && (fields & 2U) == 0U) {
if (!parse_u32(&reader, &command->game_id)) return false;
fields |= 2U;
} else if (needs_mode && strcmp(key, "mode") == 0 && (fields & 4U) == 0U) {
if (!parse_string(&reader, mode, sizeof(mode))) return false;
fields |= 4U;
} else if (needs_coordinate && strcmp(key, "x") == 0 && (fields & 4U) == 0U) {
if (!parse_u32(&reader, &x)) return false;
fields |= 4U;
} else if (needs_coordinate && strcmp(key, "y") == 0 && (fields & 8U) == 0U) {
if (!parse_u32(&reader, &y)) return false;
fields |= 8U;
} else return false;
}
const uint8_t expected = needs_coordinate ? 15U : needs_mode ? 7U : 3U;
if (!parse_object_end(&reader) || fields != expected || !parse_token(body_token, token)) return false;
if (needs_mode) {
if (strcmp(mode, "human") == 0) command->mode = MODE_HUMAN;
else if (strcmp(mode, "bot") == 0) command->mode = MODE_BOT;
else command->mode = (game_mode_t)UINT8_MAX;
}
if (needs_coordinate) {
command->coordinate = x >= kBoardWidth || y >= kBoardHeight ?
(coordinate_t){.x = kBoardWidth, .y = 0U} : (coordinate_t){.x = (uint8_t)x, .y = (uint8_t)y};
}
return true;
}
void http_api_init(http_api_t *api, application_t *application) {
if (api == NULL) return;
*api = (http_api_t){.application = application, .health = {.wifi_state = "connecting"}};
}
void http_api_set_health(http_api_t *api, const http_api_health_t *health) {
if (api != NULL && health != NULL) api->health = *health;
}
bool http_api_handle(http_api_t *api, const http_api_request_t *request, http_api_response_t *response) {
if (api == NULL || api->application == NULL || request == NULL || response == NULL) return false;
*response = (http_api_response_t){0};
game_lifecycle_t *lifecycle = &api->application->lifecycle;
const uint32_t version = lifecycle->game.state.version;
if (request->target_too_large) {
response_error(response, 413U, "PAYLOAD_TOO_LARGE", "Слишком большой запрос", version);
return true;
}
if (request->route == HTTP_API_ROUTE_INFO && request->method == HTTP_API_GET) {
const session_manager_t *sessions = &lifecycle->sessions;
uint8_t spectators = 0;
for (uint8_t index = kPlayerCapacity; index < kSessionCapacity; ++index) spectators += sessions->entries[index].occupied;
response_write(response, 200U, "{\"ok\":true,\"phase\":\"%s\",\"gameId\":%" PRIu32 ",\"version\":%" PRIu32
",\"player1Available\":%s,\"player2Available\":%s,\"spectatorsAvailable\":%u}",
phase_text(lifecycle->game.state.phase), lifecycle->game.state.game_id, version,
sessions->entries[0].occupied ? "false" : "true", sessions->entries[1].occupied ? "false" : "true",
(unsigned int)(kSpectatorCapacity - spectators));
return response->body_length <= 192U;
}
if (request->route == HTTP_API_ROUTE_HEALTH && request->method == HTTP_API_GET) {
response_write(response, 200U, "{\"ok\":true,\"uptimeMs\":%" PRIu32 ",\"wifiState\":\"%s\",\"freeHeapBytes\":%" PRIu32
",\"minimumFreeHeapBytes\":%" PRIu32 ",\"largestFreeBlockBytes\":%" PRIu32
",\"connectedClients\":%u,\"rejectedInput\":%u}", api->health.uptime_ms,
api->health.wifi_state == NULL ? "unknown" : api->health.wifi_state, api->health.free_heap_bytes,
api->health.minimum_free_heap_bytes, api->health.largest_free_block_bytes,
api->health.connected_clients, api->health.rejected_input);
return response->body_length <= 320U;
}
if (request->route == HTTP_API_ROUTE_STATE && request->method == HTTP_API_GET) {
role_t viewer = ROLE_SPECTATOR;
uint8_t token[kSessionTokenBytes];
uint8_t session_index = 0;
if (request->session_token != NULL && request->session_token[0] != '\0') {
if (!parse_token(request->session_token, token) || !application_session_for_token(api->application, token, &session_index)) {
response_error(response, 401U, "UNAUTHORIZED", "Сессия не найдена", version);
return true;
}
viewer = lifecycle->sessions.entries[session_index].role;
}
if (!state_presenter_write_lifecycle(lifecycle, viewer, response->body, sizeof(response->body), &response->body_length)) {
response_error(response, 503U, "SERVER_BUSY", "Сервер занят", version);
} else response->status = 200U;
return true;
}
if (request->route == HTTP_API_ROUTE_JOIN) {
char name[kDisplayNameBytes + 1U] = {0};
role_t role = ROLE_SPECTATOR;
if (!expect_post_body(request, 192U, response, version)) return true;
if (!parse_join(request, name, &role)) {
response_error(response, 400U, "MALFORMED_JSON", "Некорректный JSON", version);
return true;
}
if (role > ROLE_SPECTATOR) {
response_error(response, 400U, "INVALID_ROLE", "Некорректная роль", version);
return true;
}
uint8_t session_index = 0;
const lifecycle_result_t result = application_join(api->application, role, name, &session_index);
if (result != LIFECYCLE_RESULT_OK) response_lifecycle_error(response, result, lifecycle->game.state.version);
else {
char token[kSessionTokenBytes * 2U + 1U];
token_text(lifecycle->sessions.entries[session_index].token, token);
response_write(response, 200U, "{\"ok\":true,\"token\":\"%s\",\"role\":\"%s\",\"version\":%" PRIu32 ",\"gameId\":%" PRIu32 "}",
token, role_text(lifecycle->sessions.entries[session_index].role), lifecycle->game.state.version,
lifecycle->game.state.game_id);
}
return true;
}
if (request->route == HTTP_API_ROUTE_RESUME) {
char token_text_value[kSessionTokenBytes * 2U + 1U] = {0};
uint8_t token[kSessionTokenBytes];
if (!expect_post_body(request, 96U, response, version)) return true;
if (!parse_token_only(request, token_text_value) || !parse_token(token_text_value, token)) {
response_error(response, 400U, "MALFORMED_JSON", "Некорректный JSON", version);
return true;
}
uint8_t session_index = 0;
const lifecycle_result_t result = application_resume(api->application, token, &session_index);
if (result != LIFECYCLE_RESULT_OK) response_lifecycle_error(response, result, lifecycle->game.state.version);
else response_write(response, 200U, "{\"ok\":true,\"role\":\"%s\",\"version\":%" PRIu32 ",\"gameId\":%" PRIu32 "}",
role_text(lifecycle->sessions.entries[session_index].role), lifecycle->game.state.version,
lifecycle->game.state.game_id);
return true;
}
app_command_t command = {0};
uint8_t token[kSessionTokenBytes];
bool needs_mode = request->route == HTTP_API_ROUTE_CONFIG;
bool needs_coordinate = request->route == HTTP_API_ROUTE_SHOT;
size_t maximum = needs_coordinate || needs_mode ? 96U : 80U;
if (!expect_post_body(request, maximum, response, version)) return true;
if (!parse_command_with_token(request, &command, needs_mode, needs_coordinate, token)) {
response_error(response, 400U, "MALFORMED_JSON", "Некорректный JSON", version);
return true;
}
if (!application_session_for_token(api->application, token, &command.session_index)) {
response_error(response, 401U, "UNAUTHORIZED", "Сессия не найдена", version);
return true;
}
switch (request->route) {
case HTTP_API_ROUTE_CONFIG: command.type = COMMAND_CONFIG; break;
case HTTP_API_ROUTE_START: command.type = COMMAND_START; break;
case HTTP_API_ROUTE_SHOT: command.type = COMMAND_SHOT; break;
case HTTP_API_ROUTE_REMATCH: command.type = COMMAND_REMATCH; break;
case HTTP_API_ROUTE_ABORT: command.type = COMMAND_ABORT; break;
default: response_error(response, 404U, "MALFORMED_JSON", "Маршрут не найден", version); return true;
}
command.version = version;
const lifecycle_result_t result = application_submit(api->application, &command);
if (result != LIFECYCLE_RESULT_OK) response_lifecycle_error(response, result, lifecycle->game.state.version);
else response_write(response, 200U, "{\"ok\":true,\"version\":%" PRIu32 ",\"gameId\":%" PRIu32 "}",
lifecycle->game.state.version, lifecycle->game.state.game_id);
return true;
}