rewrite HTTP interface

This commit is contained in:
Martin Michelsen
2025-11-16 13:12:33 -08:00
parent 11cc19fe3e
commit 62c4c82fcc
9 changed files with 895 additions and 831 deletions
+6 -7
View File
@@ -163,7 +163,7 @@ asio::awaitable<HTTPRequest> HTTPClient::recv_http_request(size_t max_line_size,
} else if (method_token == "TRACE") {
req.method = HTTPRequest::Method::TRACE;
} else {
throw HTTPError(400, "unknown request method");
throw HTTPError(400, "Unknown request method");
}
req.http_version = std::move(line_tokens[2]);
@@ -237,26 +237,26 @@ asio::awaitable<HTTPRequest> HTTPClient::recv_http_request(size_t max_line_size,
size_t parse_offset = 0;
size_t chunk_size = stoull(line, &parse_offset, 16);
if (parse_offset != line.size()) {
throw HTTPError(400, "invalid chunk header during chunked encoding");
throw HTTPError(400, "Invalid chunk header during chunked encoding");
}
if (chunk_size == 0) {
break;
}
total_data_bytes += chunk_size;
if (total_data_bytes > max_body_size) {
throw HTTPError(400, "request data size too large");
throw HTTPError(400, "Request data size too large");
}
chunks.emplace_back(co_await this->r.read_data(chunk_size));
auto after_chunk_data = co_await this->r.read_line("\r\n", 0x20);
if (!after_chunk_data.empty()) {
throw HTTPError(400, "incorrect trailing sequence after chunk data");
throw HTTPError(400, "Incorrect trailing sequence after chunk data");
}
}
} else {
auto content_length_header = req.get_header("content-length");
size_t content_length = content_length_header ? stoull(*content_length_header) : 0;
if (content_length > max_body_size) {
throw HTTPError(400, "request data size too large");
throw HTTPError(400, "Request data size too large");
} else if (content_length > 0) {
req.data = co_await this->r.read_data(content_length);
}
@@ -289,8 +289,7 @@ asio::awaitable<WebSocketMessage> HTTPClient::recv_websocket_message(size_t max_
while (this->r.get_socket().is_open()) {
WebSocketMessage msg;
// We need at most 10 bytes to determine if there's a valid frame, or as
// little as 2
// We need at most 10 bytes to determine if there's a valid frame, or as little as 2
co_await this->r.read_data_into(msg.header, 2);
// Get the payload size
+124
View File
@@ -9,6 +9,7 @@
#include <functional>
#include <memory>
#include <optional>
#include <phosg/Encoding.hh>
#include <phosg/Hash.hh>
#include <phosg/Time.hh>
#include <string>
@@ -82,6 +83,106 @@ struct HTTPClient {
asio::awaitable<void> send_websocket_message(const std::string& data, uint8_t opcode = 0x01);
};
template <typename RetT>
class HTTPRouter {
public:
struct Args {
std::shared_ptr<HTTPClient> client;
const HTTPRequest& req;
std::unordered_map<std::string, std::string> params;
phosg::JSON post_data;
template <typename T>
requires(std::is_integral_v<T>)
T get_param(const char* name, bool hex = false) const {
const auto& value_str = this->params.at(name);
size_t conversion_end;
int64_t v = std::stoull(value_str, &conversion_end, hex ? 16 : 0);
if (conversion_end != value_str.size()) {
throw HTTPError(400, "Invalid integer value");
}
uint64_t uv = static_cast<uint64_t>(v);
if constexpr (std::is_unsigned_v<T>) {
if (uv & (~phosg::mask_for_type<T>)) {
throw HTTPError(400, "Unsigned value out of range");
}
return uv;
} else {
if (((uv & (~(phosg::mask_for_type<T> >> 1))) != 0) && ((uv & (~(phosg::mask_for_type<T> >> 1))) != (~(phosg::mask_for_type<T> >> 1)))) {
throw HTTPError(400, "Signed value out of range");
}
return v;
}
}
};
using Handler = std::function<asio::awaitable<RetT>(Args&&)>;
static std::vector<std::string> split_and_normalize_path(const std::string& path) {
auto path_tokens = phosg::split(path, '/');
while (!path_tokens.empty() && path_tokens.back().empty()) {
path_tokens.pop_back();
}
return path_tokens;
}
void add(HTTPRequest::Method method, const std::string& path_pattern, Handler handler) {
this->routes.emplace_back(Route{
.method = method, .path_tokens = this->split_and_normalize_path(path_pattern), .handler = handler});
}
asio::awaitable<RetT> call_handler(std::shared_ptr<HTTPClient> c, const HTTPRequest& req) {
Args args = {.client = c, .req = req, .params = {}, .post_data = phosg::JSON()};
auto tokens = this->split_and_normalize_path(req.path);
for (const auto& route : this->routes) {
if (route.path_tokens.size() != tokens.size()) {
continue;
}
bool matched = true;
args.params.clear();
for (size_t z = 0; z < tokens.size(); z++) {
if (route.path_tokens[z].starts_with(':')) {
args.params.emplace(route.path_tokens[z].substr(1), tokens[z]);
} else if (route.path_tokens[z] != tokens[z]) {
matched = false;
break;
}
}
if (matched) {
if (req.method != route.method) {
throw HTTPError(405, "Incorrect HTTP method");
}
if (req.method == HTTPRequest::Method::POST) {
auto* content_type = req.get_header("content-type");
if (!content_type || (*content_type != "application/json")) {
throw HTTPError(400, "POST requests must use the application/json content type");
}
try {
args.post_data = phosg::JSON::parse(req.data);
} catch (const std::exception& e) {
throw HTTPError(400, std::format("Invalid JSON: {}", e.what()));
}
}
co_return co_await route.handler(std::move(args));
}
}
throw HTTPError(404, "Request path did not match any route");
}
private:
struct Route {
HTTPRequest::Method method;
std::vector<std::string> path_tokens;
Handler handler;
};
std::vector<Route> routes;
};
struct HTTPServerLimits {
size_t max_http_request_line_size = 0x1000; // 4KB
size_t max_http_data_size = 0x200000; // 2MB
@@ -120,6 +221,29 @@ public:
protected:
HTTPServerLimits limits;
void require_GET(const HTTPRequest& req) {
if (req.method != HTTPRequest::Method::GET) {
throw HTTPError(405, "GET method required for this endpoint");
}
}
phosg::JSON require_JSON_POST(const HTTPRequest& req) {
if (req.method != HTTPRequest::Method::POST) {
throw HTTPError(405, "POST method required for this endpoint");
}
auto* content_type = req.get_header("content-type");
if (!content_type || (*content_type != "application/json")) {
throw HTTPError(400, "POST requests must use the application/json content type");
}
try {
return phosg::JSON::parse(req.data);
} catch (const std::exception& e) {
throw HTTPError(400, std::format("Invalid JSON: {}", e.what()));
}
}
// Attempts to switch the client to WebSockets. Returns true if this is done
// successfully (and the caller should then receive/send WebSocket messages),
// or false if this failed (and the caller should send an HTTP response).
+5 -1
View File
@@ -265,7 +265,11 @@ asio::awaitable<std::invoke_result_t<FnT, ArgTs...>> call_on_thread_pool(asio::t
// call_on_thread_pool coroutine has been destroyed)
auto promise = std::make_shared<AsyncPromise<ReturnT>>();
asio::post(pool, [bound = std::move(bound), promise]() mutable {
promise->set_value(bound());
try {
promise->set_value(bound());
} catch (...) {
promise->set_exception(std::current_exception());
}
});
co_return co_await promise->get();
}
+736 -792
View File
File diff suppressed because it is too large Load Diff
+6 -20
View File
@@ -4,6 +4,7 @@
#include <memory>
#include <string>
#include <variant>
#include "AsyncHTTPServer.hh"
#include "ServerState.hh"
@@ -20,28 +21,13 @@ public:
asio::awaitable<void> send_rare_drop_notification(std::shared_ptr<const phosg::JSON> message);
protected:
struct RawResponse {
std::string content_type;
std::string data;
};
std::shared_ptr<ServerState> state;
std::unordered_set<std::shared_ptr<HTTPClient>> rare_drop_subscribers;
std::shared_ptr<phosg::JSON> generate_server_version() const;
std::shared_ptr<phosg::JSON> generate_account_json(std::shared_ptr<const Account> a) const;
std::shared_ptr<phosg::JSON> generate_client_json(
std::shared_ptr<const Client> c, std::shared_ptr<const ItemNameIndex> item_name_index) const;
std::shared_ptr<phosg::JSON> generate_lobby_json(
std::shared_ptr<const Lobby> l, std::shared_ptr<const ItemNameIndex> item_name_index) const;
std::shared_ptr<phosg::JSON> generate_accounts_json() const;
std::shared_ptr<phosg::JSON> generate_clients_json() const;
std::shared_ptr<phosg::JSON> generate_server_info_json() const;
std::shared_ptr<phosg::JSON> generate_lobbies_json() const;
std::shared_ptr<phosg::JSON> generate_summary_json() const;
std::shared_ptr<phosg::JSON> generate_all_json() const;
asio::awaitable<std::shared_ptr<phosg::JSON>> generate_ep3_cards_json(bool trial) const;
std::shared_ptr<phosg::JSON> generate_common_table_list_json() const;
std::shared_ptr<phosg::JSON> generate_rare_table_list_json() const;
asio::awaitable<std::shared_ptr<phosg::JSON>> generate_common_table_json(const std::string& table_name) const;
asio::awaitable<std::shared_ptr<phosg::JSON>> generate_rare_table_json(const std::string& table_name) const;
asio::awaitable<std::shared_ptr<phosg::JSON>> generate_quest_list_json();
HTTPRouter<std::variant<RawResponse, std::shared_ptr<const phosg::JSON>>> router;
void require_GET(const HTTPRequest& req);
phosg::JSON require_POST(const HTTPRequest& req);
-3
View File
@@ -845,9 +845,6 @@ phosg::JSON QuestIndex::json() const {
{"Categories", std::move(categories_json)},
{"Quests", std::move(quests_json)},
});
// std::map<uint32_t, std::shared_ptr<Quest>> quests_by_number;
// std::map<std::string, std::shared_ptr<Quest>> quests_by_name;
// std::map<uint32_t, std::map<uint32_t, std::shared_ptr<Quest>>> quests_by_category_id_and_number;
}
shared_ptr<const Quest> QuestIndex::get(uint32_t quest_number) const {
+1 -1
View File
@@ -171,7 +171,7 @@ phosg::JSON QuestMetadata::json() const {
enemy_exp_overrides_json.emplace(key_str, exp_override);
}
auto create_item_mask_entries_json = phosg::JSON::dict();
auto create_item_mask_entries_json = phosg::JSON::list();
for (const auto& item : this->create_item_mask_entries) {
create_item_mask_entries_json.emplace_back(item.str());
}
+16 -7
View File
@@ -20,6 +20,7 @@ TeamIndex::Team::Member::Member(const phosg::JSON& json)
try {
this->account_id = json.get_int("AccountID");
} catch (const out_of_range&) {
// Old format
this->account_id = json.get_int("SerialNumber");
}
}
@@ -78,7 +79,7 @@ void TeamIndex::Team::load_config() {
this->reward_flags = json.get_int("RewardFlags");
}
void TeamIndex::Team::save_config() const {
phosg::JSON TeamIndex::Team::json() const {
phosg::JSON members_json = phosg::JSON::list();
for (const auto& it : this->members) {
members_json.emplace_back(it.second.json());
@@ -87,14 +88,17 @@ void TeamIndex::Team::save_config() const {
for (const auto& it : this->reward_keys) {
reward_keys_json.emplace_back(it);
}
phosg::JSON root = phosg::JSON::dict({
return phosg::JSON::dict({
{"Name", this->name},
{"SpentPoints", this->spent_points},
{"Members", std::move(members_json)},
{"RewardKeys", std::move(reward_keys_json)},
{"RewardFlags", this->reward_flags},
});
phosg::save_file(this->json_filename(), root.serialize(phosg::JSON::SerializeOption::FORMAT | phosg::JSON::SerializeOption::HEX_INTEGERS | phosg::JSON::SerializeOption::ESCAPE_CONTROLS_ONLY));
}
void TeamIndex::Team::save_config() const {
phosg::save_file(this->json_filename(), this->json().serialize(phosg::JSON::SerializeOption::FORMAT | phosg::JSON::SerializeOption::HEX_INTEGERS | phosg::JSON::SerializeOption::ESCAPE_CONTROLS_ONLY));
}
void TeamIndex::Team::load_flag() {
@@ -110,16 +114,21 @@ void TeamIndex::Team::load_flag() {
}
}
void TeamIndex::Team::save_flag() const {
if (!this->flag_data) {
return;
}
phosg::ImageRGBA8888N TeamIndex::Team::decode_flag_data() const {
phosg::ImageRGBA8888N img(32, 32);
for (size_t y = 0; y < 32; y++) {
for (size_t x = 0; x < 32; x++) {
img.write(x, y, phosg::rgba8888_for_argb1555(this->flag_data->at(y * 0x20 + x)));
}
}
return img;
}
void TeamIndex::Team::save_flag() const {
if (!this->flag_data) {
return;
}
auto img = this->decode_flag_data();
phosg::save_file(this->flag_filename(), img.serialize(phosg::ImageFormat::WINDOWS_BITMAP));
}
+1
View File
@@ -76,6 +76,7 @@ public:
void load_config();
void save_config() const;
void load_flag();
phosg::ImageRGBA8888N decode_flag_data() const;
void save_flag() const;
void delete_files() const;