Add unit testing, stabilize the container and image listing
This commit is contained in:
parent
802550f6e7
commit
91fa50370a
12 changed files with 394 additions and 66 deletions
|
|
@ -33,6 +33,8 @@ add_executable(tux-dock
|
|||
main.cpp
|
||||
src/process_runner.cpp
|
||||
src/docker_engine_client.cpp
|
||||
src/http_response_parser.cpp
|
||||
src/container_parser.cpp
|
||||
src/docker_manager.cpp
|
||||
)
|
||||
|
||||
|
|
@ -45,3 +47,23 @@ target_link_libraries(tux-dock
|
|||
)
|
||||
|
||||
target_compile_options(tux-dock PRIVATE -Wall -Wextra -Wpedantic)
|
||||
|
||||
include(CTest)
|
||||
if(BUILD_TESTING)
|
||||
add_executable(tux-dock-http-tests
|
||||
tests/test_http_response_parser.cpp
|
||||
src/http_response_parser.cpp
|
||||
)
|
||||
target_include_directories(tux-dock-http-tests PRIVATE src)
|
||||
target_compile_options(tux-dock-http-tests PRIVATE -Wall -Wextra -Wpedantic)
|
||||
add_test(NAME tux-dock-http-tests COMMAND tux-dock-http-tests)
|
||||
|
||||
add_executable(tux-dock-container-tests
|
||||
tests/test_container_parser.cpp
|
||||
src/container_parser.cpp
|
||||
)
|
||||
target_include_directories(tux-dock-container-tests PRIVATE src)
|
||||
target_link_libraries(tux-dock-container-tests PRIVATE nlohmann_json::nlohmann_json)
|
||||
target_compile_options(tux-dock-container-tests PRIVATE -Wall -Wextra -Wpedantic)
|
||||
add_test(NAME tux-dock-container-tests COMMAND tux-dock-container-tests)
|
||||
endif()
|
||||
|
|
|
|||
53
main.cpp
53
main.cpp
|
|
@ -18,7 +18,7 @@
|
|||
|
||||
class TuxDockApp {
|
||||
public:
|
||||
void Run();
|
||||
int Run();
|
||||
|
||||
private:
|
||||
enum class ModalMode { None, Input, Confirm, Select, Message };
|
||||
|
|
@ -69,6 +69,8 @@ private:
|
|||
void PromptImageSelection(const std::string&, std::function<void(const std::string&, const std::string&)>);
|
||||
void RunDeferredStatusAction(const std::string&, std::function<std::string()>);
|
||||
void RefreshState(const std::string& message = "Refreshing Docker state...");
|
||||
void ApplyRefreshResults(DockerManager::ListResult<DockerManager::ContainerInfo> containers,
|
||||
DockerManager::ListResult<DockerManager::ImageInfo> images);
|
||||
static void ClearTerminal();
|
||||
void RunWithRestoredIO(const std::function<void()>&, bool clear_before = false, bool clear_after = false);
|
||||
bool OnEvent(ftxui::Event); ftxui::Element Render() const; ftxui::Element RenderModal() const;
|
||||
|
|
@ -114,11 +116,31 @@ void TuxDockApp::RefreshState(const std::string& message) {
|
|||
auto containers = docker_.getContainerList();
|
||||
auto images = docker_.getImageList();
|
||||
if (active == nullptr || ftxui::ScreenInteractive::Active() != active) return;
|
||||
active->Post([this, containers = std::move(containers), images = std::move(images)]() mutable { containers_ = std::move(containers); images_ = std::move(images); SetStatus("Docker state refreshed."); });
|
||||
active->Post([this, containers = std::move(containers), images = std::move(images)]() mutable {
|
||||
ApplyRefreshResults(std::move(containers), std::move(images));
|
||||
});
|
||||
active->PostEvent(ftxui::Event::Custom);
|
||||
});
|
||||
}
|
||||
|
||||
void TuxDockApp::ApplyRefreshResults(
|
||||
DockerManager::ListResult<DockerManager::ContainerInfo> containers,
|
||||
DockerManager::ListResult<DockerManager::ImageInfo> images) {
|
||||
std::string error;
|
||||
if (containers.ok()) {
|
||||
containers_ = std::move(containers.items);
|
||||
} else {
|
||||
error = "Containers: " + containers.error;
|
||||
}
|
||||
if (images.ok()) {
|
||||
images_ = std::move(images.items);
|
||||
} else {
|
||||
if (!error.empty()) error += "\n";
|
||||
error += "Images: " + images.error;
|
||||
}
|
||||
SetStatus(error.empty() ? "Docker state refreshed." : "Refresh failed; cached state preserved.\n" + error);
|
||||
}
|
||||
|
||||
void TuxDockApp::OpenInput(const std::string& title, const std::string& text, std::function<void(bool, const std::string&)> callback, bool secret) { modal_mode_ = ModalMode::Input; modal_title_ = title; modal_text_ = text; modal_input_.clear(); ftxui::InputOption option; option.password = secret; input_component_ = ftxui::Input(&modal_input_, "Type here", option); input_callback_ = std::move(callback); }
|
||||
void TuxDockApp::OpenConfirm(const std::string& title, const std::string& text, std::function<void(bool)> callback) { modal_mode_ = ModalMode::Confirm; modal_title_ = title; modal_text_ = text; confirm_callback_ = std::move(callback); }
|
||||
void TuxDockApp::OpenSelect(const std::string& title, const std::string& text, std::vector<std::string> options, std::function<void(bool, int)> callback) { modal_mode_ = ModalMode::Select; modal_title_ = title; modal_text_ = text; modal_select_entries_ = std::move(options); modal_select_index_ = 0; select_component_ = ftxui::Menu(&modal_select_entries_, &modal_select_index_); select_callback_ = std::move(callback); }
|
||||
|
|
@ -221,8 +243,28 @@ ftxui::Element TuxDockApp::RenderModal() const {
|
|||
size(HEIGHT, LESS_THAN, 24) | size(HEIGHT, GREATER_THAN, 10) | center;
|
||||
}
|
||||
ftxui::Element TuxDockApp::Render() const { using namespace ftxui; Elements lines; std::stringstream s(status_); std::string line; while (std::getline(s, line)) lines.push_back(line.empty() ? text(" ") : text(line)); auto base = hbox(Elements{window(text("Actions"), vbox(Elements{menu_component_->Render() | frame | vscroll_indicator, separator(), text("Up/Down: navigate Enter: select") | dim})) | size(WIDTH, GREATER_THAN, 48) | flex, separator(), window(text("Status"), vbox(Elements{vbox(std::move(lines)) | yflex | frame | vscroll_indicator, separator(), text("Engine API cache") | dim})) | size(WIDTH, GREATER_THAN, 48) | flex}) | border; return modal_mode_ == ModalMode::None ? base : dbox({base, RenderModal() | clear_under | center}); }
|
||||
void TuxDockApp::Run() {
|
||||
RefreshState("Connecting to Docker...");
|
||||
int TuxDockApp::Run() {
|
||||
std::string connection_error;
|
||||
if (!docker_.checkConnection(connection_error)) {
|
||||
std::cerr << "Unable to connect to Docker Engine.\n"
|
||||
<< "Socket: /var/run/docker.sock\n"
|
||||
<< "Reason: " << connection_error << "\n\n"
|
||||
<< "Ensure Docker is running and your user can access the Docker socket.\n";
|
||||
return 1;
|
||||
}
|
||||
|
||||
const auto initial_containers = docker_.getContainerList();
|
||||
const auto initial_images = docker_.getImageList();
|
||||
if (initial_containers.ok()) containers_ = initial_containers.items;
|
||||
if (initial_images.ok()) images_ = initial_images.items;
|
||||
if (!initial_containers.ok() || !initial_images.ok()) {
|
||||
SetStatus("Docker connected, but initial state could not be fully loaded.");
|
||||
} else if (containers_.empty() && images_.empty()) {
|
||||
SetStatus("Docker connected. No containers or images found.");
|
||||
} else {
|
||||
SetStatus("Docker connected. State loaded.");
|
||||
}
|
||||
|
||||
auto root = ftxui::Renderer(menu_component_, [this] { return Render(); });
|
||||
auto app = ftxui::CatchEvent(root, [this](ftxui::Event e) { return OnEvent(e); });
|
||||
auto screen = ftxui::ScreenInteractive::TerminalOutput();
|
||||
|
|
@ -231,5 +273,6 @@ void TuxDockApp::Run() {
|
|||
screen_ = nullptr;
|
||||
if (refresh_thread_.joinable()) refresh_thread_.join();
|
||||
for (auto& thread : action_threads_) if (thread.joinable()) thread.join();
|
||||
return 0;
|
||||
}
|
||||
int main() { TuxDockApp app; app.Run(); return 0; }
|
||||
int main() { TuxDockApp app; return app.Run(); }
|
||||
|
|
|
|||
63
src/container_parser.cpp
Normal file
63
src/container_parser.cpp
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
#include "container_parser.hpp"
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <sstream>
|
||||
|
||||
using json = nlohmann::json;
|
||||
|
||||
namespace {
|
||||
|
||||
std::string optionalString(const json& object, const char* key) {
|
||||
const auto it = object.find(key);
|
||||
return it != object.end() && it->is_string() ? it->get<std::string>() : std::string{};
|
||||
}
|
||||
|
||||
std::string portsFor(const json& item) {
|
||||
const auto it = item.find("Ports");
|
||||
if (it == item.end() || !it->is_array()) return {};
|
||||
std::ostringstream result;
|
||||
bool first = true;
|
||||
for (const auto& port : *it) {
|
||||
if (!port.is_object()) continue;
|
||||
const auto public_it = port.find("PublicPort");
|
||||
const auto private_it = port.find("PrivatePort");
|
||||
if (public_it == port.end() || private_it == port.end() ||
|
||||
!public_it->is_number() || !private_it->is_number()) continue;
|
||||
if (!first) result << ", ";
|
||||
result << *public_it << ":" << *private_it;
|
||||
first = false;
|
||||
}
|
||||
return result.str();
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
DockerManager::ListResult<DockerManager::ContainerInfo>
|
||||
parseContainerList(const std::string& body) {
|
||||
DockerManager::ListResult<DockerManager::ContainerInfo> result;
|
||||
try {
|
||||
const auto parsed = json::parse(body);
|
||||
if (!parsed.is_array()) {
|
||||
result.error = "Container response is not an array.";
|
||||
return result;
|
||||
}
|
||||
for (const auto& item : parsed) {
|
||||
if (!item.is_object()) continue;
|
||||
DockerManager::ContainerInfo info;
|
||||
info.id = optionalString(item, "Id");
|
||||
const auto names = item.find("Names");
|
||||
if (names != item.end() && names->is_array() && !names->empty() && names->front().is_string()) {
|
||||
info.name = names->front().get<std::string>();
|
||||
}
|
||||
if (!info.name.empty() && info.name.front() == '/') info.name.erase(0, 1);
|
||||
info.status = optionalString(item, "Status");
|
||||
info.running = optionalString(item, "State") == "running";
|
||||
info.ports = portsFor(item);
|
||||
if (!info.id.empty() && !info.name.empty()) result.items.push_back(std::move(info));
|
||||
}
|
||||
} catch (const json::exception& error) {
|
||||
result.error = std::string("Could not parse container data: ") + error.what();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
9
src/container_parser.hpp
Normal file
9
src/container_parser.hpp
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
#pragma once
|
||||
|
||||
#include "docker_manager.hpp"
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
DockerManager::ListResult<DockerManager::ContainerInfo>
|
||||
parseContainerList(const std::string& body);
|
||||
|
|
@ -1,5 +1,7 @@
|
|||
#include "docker_engine_client.hpp"
|
||||
|
||||
#include "http_response_parser.hpp"
|
||||
|
||||
#include <cerrno>
|
||||
#include <cstring>
|
||||
#include <sys/socket.h>
|
||||
|
|
@ -12,6 +14,23 @@
|
|||
DockerEngineClient::DockerEngineClient(std::string socket_path)
|
||||
: socket_path_(std::move(socket_path)) {}
|
||||
|
||||
bool DockerEngineClient::checkConnection(std::string& error) const {
|
||||
const EngineResponse response = request("GET", "/_ping");
|
||||
if (!response.error.empty()) {
|
||||
error = response.error;
|
||||
return false;
|
||||
}
|
||||
if (response.status_code < 200 || response.status_code >= 300) {
|
||||
error = "Docker Engine returned HTTP status " + std::to_string(response.status_code) + ".";
|
||||
return false;
|
||||
}
|
||||
if (response.body != "OK" && response.body != "OK\n" && response.body != "OK\r\n") {
|
||||
error = "Docker Engine returned an unexpected /_ping response.";
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
EngineResponse DockerEngineClient::request(const std::string& method,
|
||||
const std::string& path,
|
||||
const std::string& body) const {
|
||||
|
|
@ -72,22 +91,9 @@ EngineResponse DockerEngineClient::request(const std::string& method,
|
|||
return response;
|
||||
}
|
||||
|
||||
const std::size_t header_end = raw.find("\r\n\r\n");
|
||||
if (header_end == std::string::npos) {
|
||||
response.error = "Invalid response from Docker Engine.";
|
||||
return response;
|
||||
}
|
||||
const std::size_t status_end = raw.find("\r\n");
|
||||
std::istringstream status_line(raw.substr(0, status_end));
|
||||
std::string http_version;
|
||||
status_line >> http_version >> response.status_code;
|
||||
if (response.status_code == 0) {
|
||||
response.error = "Invalid HTTP status from Docker Engine.";
|
||||
return response;
|
||||
}
|
||||
response.body = raw.substr(header_end + 4);
|
||||
if (!response.ok()) {
|
||||
response.error = response.body.empty() ? "Docker Engine request failed." : response.body;
|
||||
}
|
||||
const auto parsed = parseHttpResponse(raw);
|
||||
response.status_code = parsed.status_code;
|
||||
response.body = parsed.body;
|
||||
response.error = parsed.error;
|
||||
return response;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@ class DockerEngineClient {
|
|||
public:
|
||||
explicit DockerEngineClient(std::string socket_path = "/var/run/docker.sock");
|
||||
|
||||
bool checkConnection(std::string& error) const;
|
||||
|
||||
EngineResponse request(const std::string& method,
|
||||
const std::string& path,
|
||||
const std::string& body = {}) const;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
#include "docker_manager.hpp"
|
||||
|
||||
#include "process_runner.hpp"
|
||||
#include "container_parser.hpp"
|
||||
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
|
|
@ -12,20 +13,6 @@ using json = nlohmann::json;
|
|||
|
||||
namespace {
|
||||
|
||||
std::string joinPorts(const json& ports) {
|
||||
std::ostringstream result;
|
||||
bool first = true;
|
||||
for (const auto& port : ports) {
|
||||
const auto public_port = port.value("PublicPort", 0);
|
||||
const auto private_port = port.value("PrivatePort", 0);
|
||||
if (public_port == 0 || private_port == 0) continue;
|
||||
if (!first) result << ", ";
|
||||
result << public_port << ":" << private_port;
|
||||
first = false;
|
||||
}
|
||||
return result.str();
|
||||
}
|
||||
|
||||
std::string apiError(const EngineResponse& response, const std::string& fallback) {
|
||||
if (!response.error.empty()) return response.error;
|
||||
return fallback;
|
||||
|
|
@ -53,47 +40,42 @@ bool DockerManager::runProcess(const std::vector<std::string>& args,
|
|||
return true;
|
||||
}
|
||||
|
||||
std::vector<DockerManager::ContainerInfo> DockerManager::getContainerList() const {
|
||||
std::vector<ContainerInfo> containers;
|
||||
const EngineResponse response = engine_.request("GET", "/containers/json?all=true");
|
||||
if (!response.ok()) return containers;
|
||||
|
||||
try {
|
||||
for (const auto& item : json::parse(response.body)) {
|
||||
ContainerInfo info;
|
||||
info.id = item.value("Id", "");
|
||||
info.name = item.value("Names", std::vector<std::string>{}).empty()
|
||||
? ""
|
||||
: item.value("Names", std::vector<std::string>{}).front();
|
||||
if (!info.name.empty() && info.name.front() == '/') info.name.erase(0, 1);
|
||||
info.status = item.value("Status", "");
|
||||
info.running = item.value("State", "") == "running";
|
||||
info.ports = joinPorts(item.value("Ports", json::array()));
|
||||
if (!info.id.empty() && !info.name.empty()) containers.push_back(std::move(info));
|
||||
}
|
||||
} catch (const json::exception&) {
|
||||
return {};
|
||||
}
|
||||
return containers;
|
||||
bool DockerManager::checkConnection(std::string& error) const {
|
||||
return engine_.checkConnection(error);
|
||||
}
|
||||
|
||||
std::vector<DockerManager::ImageInfo> DockerManager::getImageList() const {
|
||||
std::vector<ImageInfo> images;
|
||||
DockerManager::ListResult<DockerManager::ContainerInfo> DockerManager::getContainerList() const {
|
||||
ListResult<ContainerInfo> result;
|
||||
const EngineResponse response = engine_.request("GET", "/containers/json?all=true");
|
||||
if (!response.ok()) {
|
||||
result.error = apiError(response, "Could not list containers.");
|
||||
return result;
|
||||
}
|
||||
|
||||
return parseContainerList(response.body);
|
||||
}
|
||||
|
||||
DockerManager::ListResult<DockerManager::ImageInfo> DockerManager::getImageList() const {
|
||||
ListResult<ImageInfo> result;
|
||||
const EngineResponse response = engine_.request("GET", "/images/json");
|
||||
if (!response.ok()) return images;
|
||||
if (!response.ok()) {
|
||||
result.error = apiError(response, "Could not list images.");
|
||||
return result;
|
||||
}
|
||||
|
||||
try {
|
||||
for (const auto& item : json::parse(response.body)) {
|
||||
const std::string id = item.value("Id", "");
|
||||
const auto tags = item.value("RepoTags", std::vector<std::string>{});
|
||||
if (id.empty()) continue;
|
||||
if (tags.empty()) images.emplace_back(id, "<untagged>");
|
||||
else for (const auto& tag : tags) images.emplace_back(id, tag);
|
||||
if (tags.empty()) result.items.emplace_back(id, "<untagged>");
|
||||
else for (const auto& tag : tags) result.items.emplace_back(id, tag);
|
||||
}
|
||||
} catch (const json::exception&) {
|
||||
return {};
|
||||
} catch (const json::exception& error) {
|
||||
result.items.clear();
|
||||
result.error = std::string("Could not parse image data: ") + error.what();
|
||||
}
|
||||
return images;
|
||||
return result;
|
||||
}
|
||||
|
||||
bool DockerManager::pullImage(const std::string& image, std::string& message) const {
|
||||
|
|
|
|||
|
|
@ -18,8 +18,18 @@ public:
|
|||
|
||||
using ImageInfo = std::pair<std::string, std::string>;
|
||||
|
||||
std::vector<ContainerInfo> getContainerList() const;
|
||||
std::vector<ImageInfo> getImageList() const;
|
||||
template <typename T>
|
||||
struct ListResult {
|
||||
std::vector<T> items;
|
||||
std::string error;
|
||||
|
||||
bool ok() const { return error.empty(); }
|
||||
};
|
||||
|
||||
bool checkConnection(std::string& error) const;
|
||||
|
||||
ListResult<ContainerInfo> getContainerList() const;
|
||||
ListResult<ImageInfo> getImageList() const;
|
||||
|
||||
bool pullImage(const std::string& image, std::string& message) const;
|
||||
bool runContainerInteractive(const std::string& image,
|
||||
|
|
|
|||
108
src/http_response_parser.cpp
Normal file
108
src/http_response_parser.cpp
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
#include "http_response_parser.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <sstream>
|
||||
|
||||
namespace {
|
||||
|
||||
std::string lower(std::string value) {
|
||||
std::transform(value.begin(), value.end(), value.begin(), [](unsigned char c) {
|
||||
return static_cast<char>(std::tolower(c));
|
||||
});
|
||||
return value;
|
||||
}
|
||||
|
||||
std::string trim(std::string value) {
|
||||
const auto first = value.find_first_not_of(" \t\r\n");
|
||||
if (first == std::string::npos) return {};
|
||||
const auto last = value.find_last_not_of(" \t\r\n");
|
||||
return value.substr(first, last - first + 1);
|
||||
}
|
||||
|
||||
bool decodeChunked(const std::string& input, std::string& body, std::string& error) {
|
||||
std::size_t offset = 0;
|
||||
while (offset < input.size()) {
|
||||
const auto line_end = input.find("\r\n", offset);
|
||||
if (line_end == std::string::npos) {
|
||||
error = "Truncated chunk size.";
|
||||
return false;
|
||||
}
|
||||
const std::string size_text = trim(input.substr(offset, line_end - offset));
|
||||
const auto semicolon = size_text.find(';');
|
||||
const std::string size_value = size_text.substr(0, semicolon);
|
||||
std::size_t chunk_size = 0;
|
||||
try {
|
||||
chunk_size = std::stoull(size_value, nullptr, 16);
|
||||
} catch (...) {
|
||||
error = "Invalid chunk size.";
|
||||
return false;
|
||||
}
|
||||
offset = line_end + 2;
|
||||
if (chunk_size == 0) return true;
|
||||
if (offset + chunk_size + 2 > input.size() || input.substr(offset + chunk_size, 2) != "\r\n") {
|
||||
error = "Truncated chunk data.";
|
||||
return false;
|
||||
}
|
||||
body.append(input, offset, chunk_size);
|
||||
offset += chunk_size + 2;
|
||||
}
|
||||
error = "Missing terminating chunk.";
|
||||
return false;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
ParsedHttpResponse parseHttpResponse(const std::string& raw) {
|
||||
ParsedHttpResponse response;
|
||||
const auto header_end = raw.find("\r\n\r\n");
|
||||
if (header_end == std::string::npos) {
|
||||
response.error = "Invalid response from Docker Engine: missing headers.";
|
||||
return response;
|
||||
}
|
||||
|
||||
const auto status_end = raw.find("\r\n");
|
||||
std::istringstream status_line(raw.substr(0, status_end));
|
||||
std::string version;
|
||||
status_line >> version >> response.status_code;
|
||||
if (response.status_code == 0) {
|
||||
response.error = "Invalid HTTP status from Docker Engine.";
|
||||
return response;
|
||||
}
|
||||
|
||||
std::size_t content_length = std::string::npos;
|
||||
bool chunked = false;
|
||||
std::size_t line_start = status_end + 2;
|
||||
while (line_start < header_end) {
|
||||
const auto line_end = raw.find("\r\n", line_start);
|
||||
if (line_end == std::string::npos || line_end > header_end) break;
|
||||
const auto separator = raw.find(':', line_start);
|
||||
if (separator != std::string::npos && separator < line_end) {
|
||||
const auto name = lower(trim(raw.substr(line_start, separator - line_start)));
|
||||
const auto value = lower(trim(raw.substr(separator + 1, line_end - separator - 1)));
|
||||
if (name == "content-length") {
|
||||
try { content_length = std::stoull(value); } catch (...) { response.error = "Invalid Content-Length."; return response; }
|
||||
} else if (name == "transfer-encoding" && value.find("chunked") != std::string::npos) {
|
||||
chunked = true;
|
||||
}
|
||||
}
|
||||
line_start = line_end + 2;
|
||||
}
|
||||
|
||||
const std::string payload = raw.substr(header_end + 4);
|
||||
if (chunked) {
|
||||
if (!decodeChunked(payload, response.body, response.error)) return response;
|
||||
} else if (content_length != std::string::npos) {
|
||||
if (payload.size() < content_length) {
|
||||
response.error = "Truncated HTTP response body.";
|
||||
return response;
|
||||
}
|
||||
response.body = payload.substr(0, content_length);
|
||||
} else {
|
||||
response.body = payload;
|
||||
}
|
||||
if (response.status_code < 200 || response.status_code >= 300) {
|
||||
response.error = response.body.empty() ? "Docker Engine request failed." : response.body;
|
||||
}
|
||||
return response;
|
||||
}
|
||||
13
src/http_response_parser.hpp
Normal file
13
src/http_response_parser.hpp
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
#pragma once
|
||||
|
||||
#include <string>
|
||||
|
||||
struct ParsedHttpResponse {
|
||||
int status_code = 0;
|
||||
std::string body;
|
||||
std::string error;
|
||||
|
||||
bool ok() const { return status_code >= 200 && status_code < 300 && error.empty(); }
|
||||
};
|
||||
|
||||
ParsedHttpResponse parseHttpResponse(const std::string& raw);
|
||||
27
tests/test_container_parser.cpp
Normal file
27
tests/test_container_parser.cpp
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
#include "container_parser.hpp"
|
||||
|
||||
#include <cassert>
|
||||
#include <iostream>
|
||||
|
||||
int main() {
|
||||
const auto result = parseContainerList(R"([
|
||||
{"Id":"running-id","Names":["/web"],"State":"running","Status":"Up 2 minutes","Ports":[]},
|
||||
{"Id":"exited-id","Names":["/old"],"State":"exited","Status":"Exited (0) 1 minute ago","Ports":[{"PrivatePort":80,"Type":"tcp"}]},
|
||||
{"Id":"odd-id","Names":["/odd"],"State":"exited","Ports":"unexpected"}
|
||||
])");
|
||||
assert(result.ok());
|
||||
assert(result.items.size() == 3);
|
||||
assert(result.items[0].running);
|
||||
assert(!result.items[1].running);
|
||||
assert(result.items[1].name == "old");
|
||||
assert(result.items[1].ports.empty());
|
||||
assert(result.items[2].name == "odd");
|
||||
|
||||
const auto empty = parseContainerList("[]");
|
||||
assert(empty.ok());
|
||||
assert(empty.items.empty());
|
||||
|
||||
const auto invalid = parseContainerList("not-json");
|
||||
assert(!invalid.ok());
|
||||
std::cout << "Container parser tests passed\n";
|
||||
}
|
||||
43
tests/test_http_response_parser.cpp
Normal file
43
tests/test_http_response_parser.cpp
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
#include "http_response_parser.hpp"
|
||||
|
||||
#include <cassert>
|
||||
#include <iostream>
|
||||
|
||||
void testContentLength() {
|
||||
const auto response = parseHttpResponse(
|
||||
"HTTP/1.1 200 OK\r\nContent-Length: 6\r\n\r\n[1,2]\n");
|
||||
if (!response.ok()) std::cerr << response.error << " status=" << response.status_code << " body=" << response.body << "\n";
|
||||
assert(response.ok());
|
||||
assert(response.body == "[1,2]\n");
|
||||
}
|
||||
|
||||
void testChunked() {
|
||||
const auto response = parseHttpResponse(
|
||||
"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n"
|
||||
"5\r\nhello\r\n6\r\n world\r\n0\r\n\r\n");
|
||||
assert(response.ok());
|
||||
assert(response.body == "hello world");
|
||||
}
|
||||
|
||||
void testTruncatedBody() {
|
||||
const auto response = parseHttpResponse(
|
||||
"HTTP/1.1 200 OK\r\nContent-Length: 10\r\n\r\nshort");
|
||||
assert(!response.ok());
|
||||
assert(response.error == "Truncated HTTP response body.");
|
||||
}
|
||||
|
||||
void testErrorResponse() {
|
||||
const auto response = parseHttpResponse(
|
||||
"HTTP/1.1 500 Internal Server Error\r\nContent-Length: 3\r\n\r\nbad");
|
||||
assert(!response.ok());
|
||||
assert(response.status_code == 500);
|
||||
assert(response.error == "bad");
|
||||
}
|
||||
|
||||
int main() {
|
||||
testContentLength();
|
||||
testChunked();
|
||||
testTruncatedBody();
|
||||
testErrorResponse();
|
||||
std::cout << "HTTP response parser tests passed\n";
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue