Skip to content
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -96,3 +96,4 @@ services/vroom/data/
Claude.md
.claude/
.cursor/
Agents.md
2 changes: 2 additions & 0 deletions app/api/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ target_sources(
src/endpoints/optimization_jobs_endpoint.cpp
src/endpoints/optimize_endpoint.cpp
src/endpoints/osrm_proxy_endpoint.cpp
src/endpoints/whatsapp_endpoint.cpp
src/optimization_job_runtime.cpp
src/optimization_job_store.cpp
src/observability.cpp
Expand All @@ -34,6 +35,7 @@ target_sources(
include/deliveryoptimizer/api/endpoints/optimization_jobs_endpoint.hpp
include/deliveryoptimizer/api/endpoints/optimize_endpoint.hpp
include/deliveryoptimizer/api/endpoints/osrm_proxy_endpoint.hpp
include/deliveryoptimizer/api/endpoints/whatsapp_endpoint.hpp
include/deliveryoptimizer/api/forecast_optimizer.hpp
include/deliveryoptimizer/api/internal/json_utils.hpp
include/deliveryoptimizer/api/optimization_job_runtime.hpp
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
#pragma once

namespace drogon {
class HttpAppFramework;
}

namespace deliveryoptimizer::api {

void RegisterWhatsAppEndpoint(drogon::HttpAppFramework& app);

} // namespace deliveryoptimizer::api
2 changes: 2 additions & 0 deletions app/api/src/api_server.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
#include "deliveryoptimizer/api/endpoints/optimization_jobs_endpoint.hpp"
#include "deliveryoptimizer/api/endpoints/optimize_endpoint.hpp"
#include "deliveryoptimizer/api/endpoints/osrm_proxy_endpoint.hpp"
#include "deliveryoptimizer/api/endpoints/whatsapp_endpoint.hpp"
#include "deliveryoptimizer/api/observability.hpp"
#include "deliveryoptimizer/api/optimization_job_runtime.hpp"
#include "deliveryoptimizer/api/optimization_job_store.hpp"
Expand Down Expand Up @@ -147,6 +148,7 @@ int RunApiServer() {
RegisterDeliveriesOptimizeEndpoint(app, options.solve_admission, observability);
}
RegisterOsrmProxyEndpoint(app);
RegisterWhatsAppEndpoint(app);

app.addListener("0.0.0.0", options.listen_port);
// Parser-generated 413 responses bypass request/response advices, so we keep a
Expand Down
196 changes: 196 additions & 0 deletions app/api/src/endpoints/whatsapp_endpoint.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
#include "deliveryoptimizer/api/endpoints/whatsapp_endpoint.hpp"

#include "env_utils.hpp"

#include <algorithm>
#include <cctype>
#include <drogon/drogon.h>
#include <functional>
#include <json/json.h>
#include <optional>
#include <string>
#include <string_view>
#include <utility>

namespace {

constexpr std::string_view kDefaultWhatsAppBaseUrl = "https://graph.facebook.com";
constexpr char kWhatsAppBaseUrlEnv[] = "WHATSAPP_API_BASE_URL";
constexpr std::string_view kDefaultWhatsAppApiVersion = "v23.0";
constexpr char kWhatsAppApiVersionEnv[] = "WHATSAPP_API_VERSION";
constexpr char kWhatsAppAccessTokenEnv[] = "WHATSAPP_ACCESS_TOKEN";
constexpr char kWhatsAppPhoneNumberIdEnv[] = "WHATSAPP_PHONE_NUMBER_ID";
constexpr char kWhatsAppSendSecretEnv[] = "WHATSAPP_SEND_ROUTE_SECRET";
constexpr char kSendSecretHeader[] = "X-WhatsApp-Send-Secret";
constexpr char kSendRoutePath[] = "/api/whatsapp/send-route";
constexpr char kToField[] = "to";
constexpr char kMessageField[] = "message";

[[nodiscard]] std::string TrimWhitespace(const std::string_view value) {
std::size_t first = 0;
while (first < value.size() && std::isspace(static_cast<unsigned char>(value[first])) != 0) {
++first;
}

std::size_t last = value.size();
while (last > first && std::isspace(static_cast<unsigned char>(value[last - 1])) != 0) {
--last;
}

return std::string{value.substr(first, last - first)};
}

[[nodiscard]] std::string ResolveTrimmedEnvOrDefault(const char* key,
const std::string_view default_value) {
return TrimWhitespace(deliveryoptimizer::api::ResolveEnvOrDefault(key, default_value));
}

[[nodiscard]] std::string
ResolveTrimmedEnvOrDefaultWhenBlank(const char* key, const std::string_view default_value) {
auto value = ResolveTrimmedEnvOrDefault(key, default_value);
if (value.empty()) {
return std::string{default_value};
}

return value;
}

[[nodiscard]] drogon::HttpResponsePtr BuildJsonResponse(const Json::Value& body,
const drogon::HttpStatusCode code) {
auto response = drogon::HttpResponse::newHttpJsonResponse(body);
response->setStatusCode(code);
return response;
}

[[nodiscard]] drogon::HttpResponsePtr BuildErrorResponse(const drogon::HttpStatusCode code,
const std::string_view error_message) {
Json::Value body{Json::objectValue};
body["error"] = std::string{error_message};
return BuildJsonResponse(body, code);
}

[[nodiscard]] std::optional<std::string> ReadNonEmptyStringMember(const Json::Value& body,
const char* field_name) {
if (!body.isMember(field_name) || !body[field_name].isString()) {
return std::nullopt;
}

auto value = body[field_name].asString();
if (value.empty() || std::all_of(value.begin(), value.end(),
[](const unsigned char ch) { return std::isspace(ch) != 0; })) {
return std::nullopt;
}

return value;
}

[[nodiscard]] bool IsSuccessStatus(const drogon::HttpStatusCode status_code) {
const auto status = static_cast<int>(status_code);
return status >= 200 && status < 300;
}

[[nodiscard]] Json::Value BuildWhatsAppPayload(const std::string& to_number,
const std::string& message) {
Json::Value payload{Json::objectValue};
payload["messaging_product"] = "whatsapp";
payload["to"] = to_number;
payload["type"] = "text";
payload["text"]["body"] = message;
return payload;
}

[[nodiscard]] drogon::HttpResponsePtr
BuildUpstreamFailureResponse(const drogon::HttpResponsePtr& upstream_response) {
Json::Value body{Json::objectValue};
body["error"] = "WhatsApp upstream request failed.";
if (upstream_response != nullptr) {
body["upstream_status"] = static_cast<int>(upstream_response->getStatusCode());
}
return BuildJsonResponse(body, drogon::k502BadGateway);
}

} // namespace

namespace deliveryoptimizer::api {

void RegisterWhatsAppEndpoint(drogon::HttpAppFramework& app) {
const auto whatsapp_base_url =
ResolveNormalizedUrlEnvOrDefault(kWhatsAppBaseUrlEnv, kDefaultWhatsAppBaseUrl);
auto whatsapp_client = drogon::HttpClient::newHttpClient(whatsapp_base_url);

app.registerHandler(
kSendRoutePath,
[whatsapp_client = std::move(whatsapp_client)](
const drogon::HttpRequestPtr& request,
std::function<void(const drogon::HttpResponsePtr&)>&& callback) mutable {
const auto request_body = request->getJsonObject();
if (request_body == nullptr) {
std::move(callback)(
BuildErrorResponse(drogon::k400BadRequest, "Request body must be valid JSON."));
return;
}

const auto configured_secret = ResolveTrimmedEnvOrDefault(kWhatsAppSendSecretEnv, "");
if (configured_secret.empty()) {
std::move(callback)(
BuildErrorResponse(drogon::k503ServiceUnavailable, "WhatsApp is not configured."));
return;
}

const auto provided_secret = request->getHeader(kSendSecretHeader);
if (provided_secret != configured_secret) {
std::move(callback)(BuildErrorResponse(drogon::k401Unauthorized, "Unauthorized."));
return;
}

const auto to_number = ReadNonEmptyStringMember(*request_body, kToField);
if (!to_number.has_value()) {
std::move(callback)(
BuildErrorResponse(drogon::k400BadRequest, "Request body must include to."));
return;
}

const auto message = ReadNonEmptyStringMember(*request_body, kMessageField);
if (!message.has_value()) {
std::move(callback)(
BuildErrorResponse(drogon::k400BadRequest, "Request body must include message."));
return;
}

const auto access_token = ResolveTrimmedEnvOrDefault(kWhatsAppAccessTokenEnv, "");
const auto phone_number_id = ResolveTrimmedEnvOrDefault(kWhatsAppPhoneNumberIdEnv, "");
if (access_token.empty() || phone_number_id.empty()) {
std::move(callback)(
BuildErrorResponse(drogon::k503ServiceUnavailable, "WhatsApp is not configured."));
return;
}

const auto api_version =
ResolveTrimmedEnvOrDefaultWhenBlank(kWhatsAppApiVersionEnv, kDefaultWhatsAppApiVersion);

auto upstream_request = drogon::HttpRequest::newHttpRequest();
upstream_request->setMethod(drogon::Post);
upstream_request->setPath("/" + api_version + "/" + phone_number_id + "/messages");
upstream_request->addHeader("Authorization", "Bearer " + access_token);
upstream_request->setContentTypeCode(drogon::CT_APPLICATION_JSON);

const auto payload = BuildWhatsAppPayload(*to_number, *message);
upstream_request->setBody(payload.toStyledString());

whatsapp_client->sendRequest(
upstream_request,
[callback = std::move(callback)](const drogon::ReqResult result,
const drogon::HttpResponsePtr& response) mutable {
if (result == drogon::ReqResult::Ok && response != nullptr &&
IsSuccessStatus(response->getStatusCode())) {
std::move(callback)(response);
return;
}

std::move(callback)(BuildUpstreamFailureResponse(response));
});
},
{drogon::Post});
}

} // namespace deliveryoptimizer::api
71 changes: 71 additions & 0 deletions tests/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,77 @@ deliveryoptimizer_set_local_http_test_environment(
"DELIVERYOPTIMIZER_TEST_PORT=${osrm_proxy_test_port};DELIVERYOPTIMIZER_OSRM_STUB_PORT=${osrm_stub_test_port}"
)

deliveryoptimizer_add_local_http_test(
DeliveryOptimizerApiValidationTest.WhatsappSendRouteRejectsInvalidRequest
whatsapp_send_route_rejects_invalid_request_test.sh
30
"${DELIVERYOPTIMIZER_LOCAL_API_LABELS}"
)
deliveryoptimizer_set_local_http_test_port(
DeliveryOptimizerApiValidationTest.WhatsappSendRouteRejectsInvalidRequest
38400
)

deliveryoptimizer_add_local_http_test(
DeliveryOptimizerApiValidationTest.WhatsappSendRouteRequiresConfiguration
whatsapp_send_route_requires_configuration_test.sh
30
"${DELIVERYOPTIMIZER_LOCAL_API_LABELS}"
)
deliveryoptimizer_set_local_http_test_port(
DeliveryOptimizerApiValidationTest.WhatsappSendRouteRequiresConfiguration
38500
)

deliveryoptimizer_add_local_http_test(
DeliveryOptimizerApiValidationTest.WhatsappSendRouteRejectsUnauthorizedRequest
whatsapp_send_route_rejects_unauthorized_request_test.sh
30
"${DELIVERYOPTIMIZER_LOCAL_API_LABELS}"
)
deliveryoptimizer_set_local_http_test_port(
DeliveryOptimizerApiValidationTest.WhatsappSendRouteRejectsUnauthorizedRequest
38800
)

add_test(
NAME DeliveryOptimizerApiContractTest.WhatsappSendRouteForwardsToUpstream
COMMAND "${BASH_PROGRAM}"
"${CMAKE_CURRENT_SOURCE_DIR}/integration/http_server/whatsapp_send_route_forwards_to_upstream_test.sh"
"$<TARGET_FILE:deliveryoptimizer_api>" "${PYTHON3_PROGRAM}" "${CURL_PROGRAM}"
)
set_tests_properties(
DeliveryOptimizerApiContractTest.WhatsappSendRouteForwardsToUpstream
PROPERTIES
TIMEOUT 30
LABELS "${DELIVERYOPTIMIZER_LOCAL_API_LABELS}"
)
deliveryoptimizer_resolve_local_http_test_port(whatsapp_send_route_test_port 38600)
deliveryoptimizer_resolve_local_http_test_port(whatsapp_send_route_stub_port 52200)
deliveryoptimizer_set_local_http_test_environment(
DeliveryOptimizerApiContractTest.WhatsappSendRouteForwardsToUpstream
"DELIVERYOPTIMIZER_TEST_PORT=${whatsapp_send_route_test_port};DELIVERYOPTIMIZER_WHATSAPP_STUB_PORT=${whatsapp_send_route_stub_port}"
)

add_test(
NAME DeliveryOptimizerApiContractTest.WhatsappSendRouteReturns502ForUpstreamFailure
COMMAND "${BASH_PROGRAM}"
"${CMAKE_CURRENT_SOURCE_DIR}/integration/http_server/whatsapp_send_route_returns_502_for_upstream_failure_test.sh"
"$<TARGET_FILE:deliveryoptimizer_api>" "${PYTHON3_PROGRAM}" "${CURL_PROGRAM}"
)
set_tests_properties(
DeliveryOptimizerApiContractTest.WhatsappSendRouteReturns502ForUpstreamFailure
PROPERTIES
TIMEOUT 30
LABELS "${DELIVERYOPTIMIZER_LOCAL_API_LABELS}"
)
deliveryoptimizer_resolve_local_http_test_port(whatsapp_send_route_failure_test_port 38700)
deliveryoptimizer_resolve_local_http_test_port(whatsapp_send_route_failure_stub_port 52300)
deliveryoptimizer_set_local_http_test_environment(
DeliveryOptimizerApiContractTest.WhatsappSendRouteReturns502ForUpstreamFailure
"DELIVERYOPTIMIZER_TEST_PORT=${whatsapp_send_route_failure_test_port};DELIVERYOPTIMIZER_WHATSAPP_FAILURE_STUB_PORT=${whatsapp_send_route_failure_stub_port}"
)

deliveryoptimizer_add_local_http_test(
DeliveryOptimizerApiContractTest.OsrmProxyRejectsDisallowedService
osrm_proxy_rejects_disallowed_service_test.sh
Expand Down
Loading
Loading