userver: /data/code/userver/libraries/s3api/src/s3api/clients/client.cpp Source File
Loading...
Searching...
No Matches
client.cpp
1#include <s3api/clients/client.hpp>
2
3#include <sstream>
4
5#include <fmt/format.h>
6#include <boost/algorithm/string.hpp>
7#include <pugixml.hpp>
8
9#include <userver/http/common_headers.hpp>
10#include <userver/http/url.hpp>
11#include <userver/logging/log.hpp>
12#include <userver/utils/algo.hpp>
13#include <userver/utils/exception.hpp>
14
15#include <userver/s3api/authenticators/access_key.hpp>
16
17#include <s3api/s3_connection.hpp>
18#include <s3api/s3api_methods.hpp>
19
20USERVER_NAMESPACE_BEGIN
21
22namespace s3api {
23namespace {
24
25const std::string kMeta = "x-amz-meta-";
26const std::string kTagging = "X-Amz-Tagging";
27constexpr const std::size_t kMaxS3Keys = 1000;
28
29constexpr http::headers::PredefinedHeader kEtagHeader{"ETag"};
30
31void SaveMeta(clients::http::Headers& headers, const ClientImpl::Meta& meta) {
32 for (const auto& [header, value] : meta) {
33 headers[kMeta + header] = value;
34 }
35}
36
37void ReadMeta(const clients::http::Headers& headers, ClientImpl::Meta& meta) {
38 for (const auto& [header, value] : headers) {
39 if (boost::istarts_with(header, kMeta)) {
40 meta[header.substr(kMeta.length())] = value;
41 }
42 }
43}
44
45void SaveTags(clients::http::Headers& headers, const std::vector<ClientImpl::Tag>& tags) {
46 size_t size = 0;
47 for (const auto& [key, value] : tags) {
48 size += key.size() + value.size() + 2;
49 }
50 std::string tag_values;
51 tag_values.reserve(size);
52 for (const auto& [key, value] : tags) {
53 tag_values.append(key);
54 tag_values.append("=");
55 tag_values.append(value);
56 tag_values.append("&");
57 }
58 if (!tag_values.empty()) {
59 // pop last &
60 tag_values.pop_back();
61 }
62 headers[kTagging] = std::move(tag_values);
63}
64
65void AddQueryParamsToPresignedUrl(
66 std::ostringstream& generated_url,
67 time_t expires_at,
68 const Request& req,
69 std::shared_ptr<authenticators::Authenticator> authenticator
70) {
71 if (!req.req.empty()) {
72 generated_url << "/" + req.req;
73 }
74
75 auto params = authenticator->Sign(req, expires_at);
76
77 if (!params.empty()) {
78 generated_url << "?";
79 generated_url << USERVER_NAMESPACE::http::MakeQuery(params);
80 }
81}
82
83std::string GeneratePresignedUrl(
84 Request& request,
85 std::string_view host,
86 std::string_view protocol,
87 const std::chrono::system_clock::time_point& expires_at,
88 std::shared_ptr<authenticators::Authenticator> authenticator
89) {
90 std::ostringstream generated_url;
91 // both internal (s3.mds(t)) and private (s3-private)
92 // balancers support virtual host addressing and https
93 request.headers[USERVER_NAMESPACE::http::headers::kHost] = fmt::format("{}.{}", request.bucket, host);
94 generated_url << protocol << request.headers[USERVER_NAMESPACE::http::headers::kHost];
95
96 const auto expires_at_time_t = std::chrono::system_clock::to_time_t(expires_at);
97 AddQueryParamsToPresignedUrl(generated_url, expires_at_time_t, request, std::move(authenticator));
98 return generated_url.str();
99}
100
101bool IsS3ResponseTruncated(const pugi::xml_node& list_bucket_result) {
102 const auto is_truncated = list_bucket_result.child("IsTruncated").child_value();
103 return std::string_view{is_truncated} == "true";
104}
105
106std::vector<ObjectMeta> ParseS3ListResponse(utils::zstring_view s3_response, bool& is_truncated) {
107 std::vector<ObjectMeta> result;
108 pugi::xml_document xml;
109 const pugi::xml_parse_result parse_result = xml.load_string(s3_response.c_str());
110 if (parse_result.status != pugi::status_ok) {
111 throw ListBucketError(fmt::format(
112 "Failed to parse S3 list response as xml, error: {}, response: {}",
113 parse_result.description(),
114 s3_response
115 ));
116 }
117 try {
118 const auto list_bucket_result = xml.child("ListBucketResult");
119 is_truncated = IsS3ResponseTruncated(list_bucket_result);
120
121 const auto items = list_bucket_result.children("Contents");
122 for (const auto& item : items) {
123 const auto key = item.child("Key").child_value();
124 const auto size = std::stoull(item.child("Size").child_value());
125 const auto last_modified = item.child("LastModified").child_value();
126 result.push_back(ObjectMeta{key, size, last_modified});
127 }
128 } catch (const pugi::xpath_exception& ex) {
129 throw ListBucketError(
130 fmt::format("Bad xml structure for S3 list response, error: {}, response: {}", ex.what(), s3_response)
131 );
132 }
133 return result;
134}
135
136std::vector<std::string> ParseS3DirectoriesListResponse(utils::zstring_view s3_response, bool& is_truncated) {
137 std::vector<std::string> result;
138 pugi::xml_document xml;
139 const pugi::xml_parse_result parse_result = xml.load_string(s3_response.c_str());
140 if (parse_result.status != pugi::status_ok) {
141 throw ListBucketError(fmt::format(
142 "Failed to parse S3 directories list response as xml, error: {}, "
143 "response: {}",
144 parse_result.description(),
145 s3_response
146 ));
147 }
148 try {
149 const auto list_bucket_result = xml.child("ListBucketResult");
150 is_truncated = IsS3ResponseTruncated(list_bucket_result);
151
152 const auto items = list_bucket_result.children("CommonPrefixes");
153 for (const auto& item : items) {
154 result.push_back(item.child("Prefix").child_value());
155 }
156 } catch (const pugi::xpath_exception& ex) {
157 throw ListBucketError(fmt::format(
158 "Bad xml structure for S3 directories list response, error: {}, "
159 "response: {}",
160 ex.what(),
161 s3_response
162 ));
163 }
164 return result;
165}
166
167} // namespace
168
169void ClientImpl::UpdateConfig(ConnectionCfg&& config) { conn_->UpdateConfig(std::move(config)); }
170
171ClientImpl::ClientImpl(
172 std::shared_ptr<S3Connection> s3conn,
173 std::shared_ptr<authenticators::Authenticator> authenticator,
174 std::string bucket
175)
176 : conn_(std::move(s3conn)),
177 authenticator_{std::move(authenticator)},
178 bucket_(std::move(bucket))
179{}
180
181ClientImpl::ClientImpl(
182 std::shared_ptr<S3Connection> s3conn,
183 std::shared_ptr<authenticators::AccessKey> authenticator,
184 std::string bucket
185)
186 : ClientImpl(
187 std::move(s3conn),
188 std::static_pointer_cast<authenticators::Authenticator>(std::move(authenticator)),
189 std::move(bucket)
190 )
191{}
192
193std::string_view ClientImpl::GetBucketName() const { return bucket_; }
194
195std::string ClientImpl::PutObject(
196 std::string_view path, //
197 std::string data, //
198 const std::optional<Meta>& meta, //
199 std::string_view content_type, //
200 const std::optional<std::string>& content_disposition,
201 const std::optional<std::vector<Tag>>& tags
202) const {
203 auto req = api_methods::PutObject( //
204 bucket_, //
205 path, //
206 std::move(data), //
207 content_type, //
208 content_disposition //
209 );
210
211 if (meta.has_value()) {
212 SaveMeta(req.headers, meta.value());
213 }
214 if (tags.has_value()) {
215 SaveTags(req.headers, tags.value());
216 }
217 return RequestApi(req, "put_object");
218}
219
220void ClientImpl::DeleteObject(std::string_view path) const {
221 auto req = api_methods::DeleteObject(bucket_, path);
222 RequestApi(req, "delete_object");
223}
224
225std::optional<std::string> ClientImpl::GetObject(
226 std::string_view path,
227 std::optional<std::string> version,
228 HeadersDataResponse* headers_data,
229 const HeaderDataRequest& headers_request
230) const {
231 try {
232 return std::make_optional(TryGetObject(path, std::move(version), headers_data, headers_request));
233 } catch (const clients::http::HttpException& e) {
234 if (e.code() == 404) {
235 LOG_INFO() << "Can't get object with path: " << path << ", object not found:" << e.what();
236 } else {
237 LOG_ERROR() << "Can't get object with path: " << path << ", unknown error:" << e.what();
238 }
239 return std::nullopt;
240 } catch (const std::exception& e) {
241 LOG_ERROR() << "Can't get object with path: " << path << ", unknown error:" << e.what();
242 return std::nullopt;
243 }
244}
245
246std::string ClientImpl::TryGetObject(
247 std::string_view path,
248 std::optional<std::string> version,
249 HeadersDataResponse* headers_data,
250 const HeaderDataRequest& headers_request
251) const {
252 auto req = api_methods::GetObject(bucket_, path, std::move(version));
253 return RequestApi(req, "get_object", headers_data, headers_request);
254}
255
256std::optional<std::string> ClientImpl::GetPartialObject(
257 std::string_view path,
258 std::string_view range,
259 std::optional<std::string> version,
260 HeadersDataResponse* headers_data,
261 const HeaderDataRequest& headers_request
262) const {
263 try {
264 return std::make_optional(TryGetPartialObject(path, range, std::move(version), headers_data, headers_request));
265 } catch (const clients::http::HttpException& e) {
266 if (e.code() == 404) {
267 LOG_INFO() << "Can't get object with path: " << path << ", object not found:" << e.what();
268 } else {
269 LOG_ERROR() << "Can't get object with path: " << path << ", unknown error:" << e.what();
270 }
271 return std::nullopt;
272 } catch (const std::exception& e) {
273 LOG_ERROR() << "Can't get object with path: " << path << ", unknown error:" << e.what();
274 return std::nullopt;
275 }
276}
277
278std::string ClientImpl::TryGetPartialObject(
279 std::string_view path,
280 std::string_view range,
281 std::optional<std::string> version,
282 HeadersDataResponse* headers_data,
283 const HeaderDataRequest& headers_request
284) const {
285 auto req = api_methods::GetObject(bucket_, path, std::move(version));
286 api_methods::SetRange(req, range);
287 return RequestApi(req, "get_object", headers_data, headers_request);
288}
289
290std::optional<ClientImpl::HeadersDataResponse> ClientImpl::GetObjectHead(
291 std::string_view path,
292 const HeaderDataRequest& headers_request
293) const {
294 HeadersDataResponse headers_data;
295 auto req = api_methods::GetObjectHead(bucket_, path);
296 try {
297 RequestApi(req, "get_object_head", &headers_data, headers_request);
298 } catch (const std::exception& e) {
299 LOG_INFO() << "Can't get object with path: " << path << ", error:" << e.what();
300 return std::nullopt;
301 }
302 return std::make_optional(std::move(headers_data));
303}
304
305[[deprecated]] std::string ClientImpl::GenerateDownloadUrl(std::string_view path, time_t expires_at, bool use_ssl)
306 const {
307 auto req = api_methods::GetObject(bucket_, path);
308
309 std::ostringstream generated_url;
310 auto host = conn_->GetHost();
311
312 if (const auto scheme_pos = host.find("://"); scheme_pos == std::string::npos) {
313 generated_url << (use_ssl ? "https" : "http") << "://" << host;
314 req.headers[USERVER_NAMESPACE::http::headers::kHost] = host;
315 } else {
316 generated_url << host;
317 req.headers[USERVER_NAMESPACE::http::headers::kHost] = host.substr(scheme_pos + 3);
318 }
319
320 if (!req.bucket.empty()) {
321 generated_url << '/' << req.bucket;
322 }
323
324 AddQueryParamsToPresignedUrl(generated_url, expires_at, req, authenticator_);
325 return generated_url.str();
326}
327
328std::string ClientImpl::GenerateDownloadUrlVirtualHostAddressing(
329 std::string_view path,
330 const std::chrono::system_clock::time_point& expires_at,
331 std::string_view protocol
332) const {
333 auto req = api_methods::GetObject(bucket_, path);
334 if (req.bucket.empty()) {
335 throw NoBucketError("presigned url for empty bucket string");
336 }
337 return GeneratePresignedUrl(req, conn_->GetHost(), protocol, expires_at, authenticator_);
338}
339
340std::string ClientImpl::GenerateUploadUrlVirtualHostAddressing(
341 std::string_view data,
342 std::string_view content_type,
343 std::string_view path,
344 const std::chrono::system_clock::time_point& expires_at,
345 std::string_view protocol
346) const {
347 auto req = api_methods::PutObject(bucket_, path, std::string{data}, content_type);
348 if (req.bucket.empty()) {
349 throw NoBucketError("presigned url for empty bucket string");
350 }
351 return GeneratePresignedUrl(req, conn_->GetHost(), protocol, expires_at, authenticator_);
352}
353
354void ClientImpl::Auth(Request& request) const {
355 if (!authenticator_) {
356 // anonymous request
357 return;
358 }
359
360 auto auth_headers = authenticator_->Auth(request);
361
362 {
363 auto it = std::ranges::find_if(auth_headers, [&request](const auto& header) {
364 return request.headers.contains(header.first);
365 });
366
367 if (it != auth_headers.cend()) {
368 throw AuthHeaderConflictError{std::string{"Conflict with auth header: "} + it->first};
369 }
370 }
371
372 request.headers
373 .insert(std::make_move_iterator(std::begin(auth_headers)), std::make_move_iterator(std::end(auth_headers)));
374}
375
376std::string ClientImpl::RequestApi(
377 Request& request,
378 std::string_view method_name,
379 HeadersDataResponse* headers_data,
380 const HeaderDataRequest& headers_request
381) const {
382 request.headers[USERVER_NAMESPACE::http::headers::kHost] = conn_->GetHostHeader(request);
383 Auth(request);
384
385 auto response = conn_->RequestApi(request, method_name);
386
387 if (headers_data) {
388 if (headers_request.need_meta) {
389 headers_data->meta.emplace();
390 ReadMeta(response->headers(), *headers_data->meta);
391 }
392 if (headers_request.headers) {
393 headers_data->headers.emplace();
394 for (const auto& header : *headers_request.headers) {
395 if (auto it = response->headers().find(header); it != response->headers().end()) {
396 headers_data->headers->emplace(it->first, it->second);
397 }
398 }
399 }
400 }
401
402 return response->body();
403}
404
405std::optional<std::string> ClientImpl::ListBucketContents(
406 std::string_view path,
407 int max_keys,
408 std::string marker,
409 std::string delimiter
410) const {
411 auto req = api_methods::ListBucketContents(bucket_, path, max_keys, marker, delimiter);
412 std::string reply = RequestApi(req, "list_bucket_contents");
413 if (reply.empty()) {
414 return std::nullopt;
415 }
416 return std::optional<std::string>{std::move(reply)};
417}
418
419std::vector<ObjectMeta> ClientImpl::ListBucketContentsParsed(std::string_view path_prefix) const {
420 std::vector<ObjectMeta> result;
421 // S3 doc: specifies the key to start with when listing objects in a bucket
422 std::string marker{};
423 bool is_finished = false;
424 while (!is_finished) {
425 auto response = ListBucketContents(path_prefix, kMaxS3Keys, marker, {});
426 if (!response) {
427 LOG_WARNING() << "Empty S3 bucket listing response for path prefix " << path_prefix;
428 break;
429 }
430
431 bool is_truncated = false;
432 auto response_result = ParseS3ListResponse(*response, is_truncated);
433 if (response_result.empty()) {
434 break;
435 }
436 if (!is_truncated) {
437 is_finished = true;
438 }
439 result.insert(
440 result.end(),
441 std::make_move_iterator(response_result.begin()),
442 std::make_move_iterator(response_result.end())
443 );
444 marker = result.back().key;
445 }
446 return result;
447}
448
449std::vector<std::string> ClientImpl::ListBucketDirectories(std::string_view path_prefix) const {
450 std::vector<std::string> result;
451 // S3 doc: specifies the key to start with when listing objects in a bucket
452 std::string marker{};
453 bool is_finished = false;
454 while (!is_finished) {
455 auto response = ListBucketContents(path_prefix, kMaxS3Keys, marker, "/");
456 if (!response) {
457 LOG_WARNING() << "Empty S3 directory bucket listing response for path prefix " << path_prefix;
458 break;
459 }
460
461 bool is_truncated = false;
462 auto response_result = ParseS3DirectoriesListResponse(*response, is_truncated);
463 if (response_result.empty()) {
464 break;
465 }
466 if (!is_truncated) {
467 is_finished = true;
468 }
469 result.insert(
470 result.end(),
471 std::make_move_iterator(response_result.begin()),
472 std::make_move_iterator(response_result.end())
473 );
474 marker = result.back();
475 }
476
477 return result;
478}
479
480std::string ClientImpl::CopyObject(
481 std::string_view key_from,
482 std::string_view bucket_to,
483 std::string_view key_to,
484 const std::optional<Meta>& meta
485) {
486 const auto object_head = [&] {
487 HeaderDataRequest header_request;
488 header_request.headers.emplace();
489 header_request.headers->emplace(USERVER_NAMESPACE::http::headers::kContentType);
490 header_request.need_meta = false;
491 return GetObjectHead(key_from, header_request);
492 }();
493 if (!object_head) {
494 USERVER_NAMESPACE::utils::LogErrorAndThrow("S3Api : Failed to get object head");
495 }
496
497 const auto content_type = [&object_head]() -> std::optional<std::string> {
498 if (!object_head->headers) {
499 return std::nullopt;
500 }
501
502 return USERVER_NAMESPACE::utils::FindOptional(
503 *object_head->headers,
504 USERVER_NAMESPACE::http::headers::kContentType
505 );
506 }();
507 if (!content_type) {
508 USERVER_NAMESPACE::utils::LogErrorAndThrow("S3Api : Object head is missing `content-type` header");
509 }
510
511 auto req = api_methods::CopyObject(bucket_, key_from, bucket_to, key_to, *content_type);
512 if (meta) {
513 SaveMeta(req.headers, *meta);
514 }
515 return RequestApi(req, "copy_object");
516}
517
518std::string ClientImpl::CopyObject(
519 std::string_view key_from,
520 std::string_view key_to,
521 const std::optional<Meta>& meta
522) {
523 return CopyObject(key_from, bucket_, key_to, meta);
524}
525
527 const multipart_upload::CreateMultipartUploadRequest& request
528) const try
529{
530 auto api_request = api_methods::CreateInternalApiRequest(bucket_, request);
531 const auto api_response_body = RequestApi(api_request, "create_multipart_upload");
532 return multipart_upload::InitiateMultipartUploadResult::Parse(api_response_body);
533} catch (const ResponseParsingError& exc) {
535 fmt::format("failed to parse CreateMultipartUpload action response - {}; key: {}", exc.what(), request.key)
536 );
537}
538
539multipart_upload::UploadPartResult ClientImpl::UploadPart(const multipart_upload::UploadPartRequest& request) const try
540{
541 auto api_request = api_methods::CreateInternalApiRequest(bucket_, request);
542
543 const HeaderDataRequest expected_headers({{std::string(kEtagHeader)}}, false);
544 HeadersDataResponse response_headers_data;
545
546 RequestApi(api_request, "upload_part", &response_headers_data, expected_headers);
547 if (!response_headers_data.headers) {
548 throw ResponseParsingError("missing ETag header in response");
549 }
550 const auto iter = response_headers_data.headers->find(kEtagHeader);
551 if (iter == response_headers_data.headers->end()) {
552 throw ResponseParsingError("missing ETag header in response");
553 }
554 if (iter->second.empty()) {
555 throw ResponseParsingError("got empty ETag header value in response");
556 }
557 return {std::move(iter->second)};
558
559} catch (const ResponseParsingError& exc) {
560 throw MultipartUploadError(fmt::format(
561 "failed to parse UploadPart action response - {}; upload_id '{}'; key '{}'",
562 exc.what(),
563 request.upload_id,
564 request.key
565 ));
566}
567
569 const multipart_upload::CompleteMultipartUploadRequest& request
570) const try
571{
572 auto api_request = api_methods::CreateInternalApiRequest(bucket_, request);
573 const auto api_response_body = RequestApi(api_request, "complete_multipart_upload");
574 return multipart_upload::CompleteMultipartUploadResult::Parse(api_response_body);
575} catch (const ResponseParsingError& exc) {
576 throw MultipartUploadError(fmt::format(
577 "failed to parse CompleteMultipartUpload action response - {}; upload_id '{}'; key '{}'",
578 exc.what(),
579 request.upload_id,
580 request.key
581 ));
582}
583
584void ClientImpl::AbortMultipartUpload(const multipart_upload::AbortMultipartUploadRequest& request) const {
585 auto api_request = api_methods::CreateInternalApiRequest(bucket_, request);
586 RequestApi(api_request, "abort_multipart_upload");
587}
588
589multipart_upload::ListPartsResult ClientImpl::ListParts(const multipart_upload::ListPartsRequest& request) const try
590{
591 auto api_request = api_methods::CreateInternalApiRequest(bucket_, request);
592 const auto api_response_body = RequestApi(api_request, "list_parts");
593 return multipart_upload::ListPartsResult::Parse(api_response_body);
594} catch (const ResponseParsingError& exc) {
595 throw MultipartUploadError(fmt::format(
596 "failed to parse ListParts action response - {}; upload_id '{}'; key '{}'",
597 exc.what(),
598 request.upload_id,
599 request.key
600 ));
601}
602
604 const multipart_upload::ListMultipartUploadsRequest& request
605) const try
606{
607 auto api_request = api_methods::CreateInternalApiRequest(bucket_, request);
608 const auto api_response_body = RequestApi(api_request, "list_multipart_uploads");
609 return multipart_upload::ListMultipartUploadsResult::Parse(api_response_body);
610} catch (const ResponseParsingError& exc) {
611 throw MultipartUploadError(fmt::format("failed to parse ListMultipartUploads action response - {}", exc.what()));
612}
613
614ClientPtr GetS3Client(
615 std::shared_ptr<S3Connection> s3conn,
616 std::shared_ptr<authenticators::AccessKey> authenticator,
617 std::string bucket
618) {
619 return GetS3Client(
620 std::move(s3conn),
621 std::static_pointer_cast<authenticators::Authenticator>(authenticator),
622 std::move(bucket)
623 );
624}
625
626ClientPtr GetS3Client(
627 std::shared_ptr<S3Connection> s3conn,
628 std::shared_ptr<authenticators::Authenticator> authenticator,
629 std::string bucket
630) {
631 return std::static_pointer_cast<Client>(std::make_shared<ClientImpl>(s3conn, authenticator, bucket));
632}
633
634} // namespace s3api
635
636USERVER_NAMESPACE_END