PACS System 0.1.0
PACS DICOM system library
Loading...
Searching...
No Matches
http_client.cpp
Go to the documentation of this file.
1// BSD 3-Clause License
2// Copyright (c) 2021-2025, 🍀☀🌕🌥 🌊
3// See the LICENSE file in the project root for full license information.
4
10#include "http_client.h"
11
12#include <curl/curl.h>
13
14#include <cstddef>
15#include <memory>
16#include <mutex>
17#include <string>
18#include <string_view>
19#include <utility>
20
22
23namespace {
24
25struct curl_deleter {
26 void operator()(CURL* c) const noexcept {
27 if (c) curl_easy_cleanup(c);
28 }
29};
30using curl_ptr = std::unique_ptr<CURL, curl_deleter>;
31
32struct slist_deleter {
33 void operator()(curl_slist* s) const noexcept {
34 if (s) curl_slist_free_all(s);
35 }
36};
37using slist_ptr = std::unique_ptr<curl_slist, slist_deleter>;
38
39std::mutex& override_mutex() {
40 static std::mutex m;
41 return m;
42}
43
44transport_override& override_fn() {
45 static transport_override fn;
46 return fn;
47}
48
49// Response-size cap. Registers should answer with a small ebRS
50// RegistryResponse (typically < 100 KiB even with a long error list).
51// 8 MiB is a generous upper bound that also fits comfortably inside the
52// pugixml parse we do downstream.
53constexpr std::size_t kMaxResponseBytes = 8 * 1024 * 1024;
54
55struct response_sink {
56 std::string buffer;
57 std::string content_type;
58 bool oversize{false};
59};
60
61std::size_t write_callback(char* ptr, std::size_t size, std::size_t nmemb,
62 void* userdata) {
63 const std::size_t total = size * nmemb;
64 auto* sink = static_cast<response_sink*>(userdata);
65 if (sink->buffer.size() + total > kMaxResponseBytes) {
66 sink->oversize = true;
67 return 0;
68 }
69 sink->buffer.append(ptr, total);
70 return total;
71}
72
73// Case-insensitive "Content-Type:" header extraction. libcurl calls this
74// once per response header line including the trailing CR/LF. We copy
75// the value portion (without the header name / whitespace / CR/LF) into
76// the sink so parse_mtom_response can authoritatively read the boundary
77// parameter instead of sniffing the body.
78std::size_t header_callback(char* ptr, std::size_t size, std::size_t nmemb,
79 void* userdata) {
80 const std::size_t total = size * nmemb;
81 auto* sink = static_cast<response_sink*>(userdata);
82 constexpr std::string_view kName = "content-type:";
83 if (total < kName.size()) return total;
84 for (std::size_t i = 0; i < kName.size(); ++i) {
85 const char c = ptr[i];
86 const char lc =
87 (c >= 'A' && c <= 'Z') ? static_cast<char>(c + 32) : c;
88 if (lc != kName[i]) return total;
89 }
90 std::size_t start = kName.size();
91 while (start < total && (ptr[start] == ' ' || ptr[start] == '\t')) {
92 ++start;
93 }
94 std::size_t end = total;
95 while (end > start &&
96 (ptr[end - 1] == '\r' || ptr[end - 1] == '\n' ||
97 ptr[end - 1] == ' ' || ptr[end - 1] == '\t')) {
98 --end;
99 }
100 sink->content_type.assign(ptr + start, end - start);
101 return total;
102}
103
104} // namespace
105
107 std::lock_guard<std::mutex> lock(override_mutex());
108 override_fn() = std::move(fn);
109}
110
112 std::lock_guard<std::mutex> lock(override_mutex());
113 override_fn() = {};
114}
115
117 const std::string& content_type,
118 const std::string& body) {
119 if (opts.endpoint.empty()) {
120 return kcenon::common::make_error<http_response>(
121 static_cast<int>(error_code::transport_not_configured),
122 "http_options.endpoint is empty", std::string(error_source));
123 }
124
125 {
126 transport_override fn_copy;
127 {
128 std::lock_guard<std::mutex> lock(override_mutex());
129 fn_copy = override_fn();
130 }
131 if (fn_copy) {
133 req.url = opts.endpoint;
135 req.soap_action = opts.soap_action;
136 req.body = body;
137 req.verify_peer = opts.verify_peer;
138 return fn_copy(req);
139 }
140 }
141
142 // libcurl 7.84+ auto-inits on first curl_easy_init, but that implicit
143 // path is not thread-safe. Call curl_global_init once per process
144 // under a std::once_flag so concurrent first-use from different
145 // threads does not race. curl_global_cleanup is deliberately NOT
146 // paired here - libcurl documents that a single init for process
147 // lifetime is the supported pattern.
148 static std::once_flag s_curl_once;
149 std::call_once(s_curl_once, [] {
150 (void)curl_global_init(CURL_GLOBAL_DEFAULT);
151 });
152
153 curl_ptr curl(curl_easy_init());
154 if (!curl) {
155 return kcenon::common::make_error<http_response>(
156 static_cast<int>(error_code::transport_init_failed),
157 "curl_easy_init failed", std::string(error_source));
158 }
159
160 slist_ptr headers;
161 {
162 curl_slist* raw = nullptr;
163 const std::string ct_header = "Content-Type: " + content_type;
164 raw = curl_slist_append(raw, ct_header.c_str());
165 const std::string action_header =
166 "SOAPAction: \"" + opts.soap_action + "\"";
167 raw = curl_slist_append(raw, action_header.c_str());
168 raw = curl_slist_append(raw, "Accept: multipart/related, application/xop+xml, application/soap+xml");
169 raw = curl_slist_append(raw, "Expect:");
170 headers.reset(raw);
171 }
172
173 response_sink response;
174
175 curl_easy_setopt(curl.get(), CURLOPT_URL, opts.endpoint.c_str());
176 curl_easy_setopt(curl.get(), CURLOPT_POST, 1L);
177 curl_easy_setopt(curl.get(), CURLOPT_HTTPHEADER, headers.get());
178 curl_easy_setopt(curl.get(), CURLOPT_USERAGENT, opts.user_agent.c_str());
179 curl_easy_setopt(curl.get(), CURLOPT_POSTFIELDS, body.data());
180 curl_easy_setopt(curl.get(), CURLOPT_POSTFIELDSIZE_LARGE,
181 static_cast<curl_off_t>(body.size()));
182 curl_easy_setopt(curl.get(), CURLOPT_WRITEFUNCTION, write_callback);
183 curl_easy_setopt(curl.get(), CURLOPT_WRITEDATA, &response);
184 curl_easy_setopt(curl.get(), CURLOPT_HEADERFUNCTION, header_callback);
185 curl_easy_setopt(curl.get(), CURLOPT_HEADERDATA, &response);
186 curl_easy_setopt(curl.get(), CURLOPT_FOLLOWLOCATION, 0L);
187 curl_easy_setopt(curl.get(), CURLOPT_NOSIGNAL, 1L);
188
189 curl_easy_setopt(curl.get(), CURLOPT_CONNECTTIMEOUT_MS,
190 static_cast<long>(opts.connect_timeout.count()));
191 curl_easy_setopt(curl.get(), CURLOPT_TIMEOUT_MS,
192 static_cast<long>(opts.request_timeout.count()));
193
194 curl_easy_setopt(curl.get(), CURLOPT_SSL_VERIFYPEER,
195 opts.verify_peer ? 1L : 0L);
196 curl_easy_setopt(curl.get(), CURLOPT_SSL_VERIFYHOST,
197 opts.verify_host ? 2L : 0L);
198 if (!opts.ca_bundle_path.empty()) {
199 curl_easy_setopt(curl.get(), CURLOPT_CAINFO,
200 opts.ca_bundle_path.c_str());
201 }
202 if (!opts.client_certificate_path.empty()) {
203 curl_easy_setopt(curl.get(), CURLOPT_SSLCERT,
204 opts.client_certificate_path.c_str());
205 }
206 if (!opts.client_private_key_path.empty()) {
207 curl_easy_setopt(curl.get(), CURLOPT_SSLKEY,
208 opts.client_private_key_path.c_str());
209 }
210
211 const CURLcode rc = curl_easy_perform(curl.get());
212 if (rc != CURLE_OK) {
213 // write_callback returned 0 once the response exceeded
214 // kMaxResponseBytes, which libcurl surfaces as CURLE_WRITE_ERROR.
215 // Promote that specific case to a dedicated error code so callers
216 // can distinguish DoS protection from other transport failures.
217 if (rc == CURLE_WRITE_ERROR && response.oversize) {
218 return kcenon::common::make_error<http_response>(
220 "registry response exceeds " +
221 std::to_string(kMaxResponseBytes) +
222 " byte cap; transfer aborted",
223 std::string(error_source));
224 }
225 const bool tls =
226 rc == CURLE_PEER_FAILED_VERIFICATION || rc == CURLE_SSL_CONNECT_ERROR ||
227 rc == CURLE_SSL_CERTPROBLEM || rc == CURLE_SSL_CIPHER ||
228 rc == CURLE_SSL_ISSUER_ERROR || rc == CURLE_SSL_CACERT_BADFILE;
229 const auto code = tls ? error_code::transport_tls_error
230 : (rc == CURLE_OPERATION_TIMEDOUT
233 return kcenon::common::make_error<http_response>(
234 static_cast<int>(code),
235 std::string("libcurl error ") + std::to_string(static_cast<int>(rc)) +
236 ": " + curl_easy_strerror(rc),
237 std::string(error_source));
238 }
239
241 curl_easy_getinfo(curl.get(), CURLINFO_RESPONSE_CODE, &out.status_code);
242 out.content_type = std::move(response.content_type);
243 out.body = std::move(response.buffer);
244
245 if (out.status_code < 200 || out.status_code >= 300) {
246 return kcenon::common::make_error<http_response>(
247 static_cast<int>(error_code::transport_http_error),
248 std::string("unexpected HTTP status ") +
249 std::to_string(out.status_code),
250 std::string(error_source));
251 }
252
253 return out;
254}
255
256} // namespace kcenon::pacs::ihe::xds::detail
std::string content_type
bool oversize
std::string buffer
libcurl-backed HTTPS POST client for ITI-41 transport.
std::function< kcenon::common::Result< http_response >(const transport_request &)> transport_override
Definition http_client.h:57
kcenon::common::Result< http_response > http_post(const http_options &opts, const std::string &content_type, const std::string &body)
POST body to opts.endpoint with the given Content-Type.
void set_http_transport_override(transport_override fn)
constexpr std::string_view error_source
Short identifier used as the "source" field of make_error.
Definition errors.h:95
std::string_view code
std::string out
HTTP response captured from a POST call.
Definition http_client.h:35
Optional test hook: replaces the real libcurl transport.
Definition http_client.h:49
HTTPS transport options for the Document Source actor.
std::chrono::milliseconds connect_timeout
std::chrono::milliseconds request_timeout