32#include "XrdOuc/XrdOucJson.hh"
55using Json = nlohmann::json;
57constexpr std::array<std::string_view, 6> kTapeLocalities = {{
58 "DISK",
"TAPE",
"DISK_AND_TAPE",
"LOST",
"NONE",
"UNAVAILABLE"
72 std::string diskLifetime;
73 Json targetedMetadata;
76std::string NormalizeTapePath(
const std::string &path)
78 std::string result = path.empty() ?
"/" : path;
79 if(result.front() !=
'/') result.insert(result.begin(),
'/');
83std::string PercentEncodeUrlPathSegment(
const std::string &value,
CURL *curl)
85 if(!curl)
throw std::bad_alloc();
88 curl_easy_escape(curl, value.c_str(),
static_cast<int>(value.size()));
89 if(!escaped)
throw std::bad_alloc();
90 std::string result(escaped);
95bool IsHttpEndpointUri(
const std::string &uri)
98 if(!url.IsValid() || url.GetHostName().empty())
return false;
100 const std::string &protocol = url.GetProtocol();
101 return strcasecmp(protocol.c_str(),
"http") == 0
102 || strcasecmp(protocol.c_str(),
"https") == 0;
105bool UrlEndpointAndPath(
const std::string &input, std::string &endpoint,
106 std::string &path, std::string &error)
109 if(!url.IsValid() || url.GetHostName().empty())
111 error =
"invalid URL '" + input +
"'";
115 std::string protocol = url.GetProtocol();
116 std::transform(protocol.begin(), protocol.end(), protocol.begin(),
117 [](
unsigned char c) { return std::tolower(c); });
118 if(protocol ==
"davs") protocol =
"https";
119 else if(protocol ==
"dav") protocol =
"http";
121 if(protocol !=
"http" && protocol !=
"https")
123 error =
"unsupported URL protocol '" + url.GetProtocol()
124 +
"' for Tape REST API";
128 std::ostringstream out;
129 out << protocol <<
"://" << url.GetHostName();
130 if(url.GetPort() > 0) out <<
":" << url.GetPort();
131 endpoint = out.str();
132 path = NormalizeTapePath(url.GetPath());
136std::string FormatProblemResponse(
long statusCode,
const std::string &body)
142 const Json
json = Json::parse(body,
nullptr,
false);
143 if(!
json.is_discarded() &&
json.is_object())
145 const auto title =
json.find(
"title");
146 const auto problemDetail =
json.find(
"detail");
148 if(title !=
json.end() && title->is_string())
150 detail = title->get<std::string>();
152 if(problemDetail !=
json.end() && problemDetail->is_string())
154 if(!detail.empty()) detail +=
" - ";
155 detail += problemDetail->get<std::string>();
158 if(detail.empty()) detail = body;
161 std::ostringstream out;
162 out <<
"HTTP " << statusCode;
163 if(!detail.empty()) out <<
": " << detail;
169 uint32_t errorNumber = EIO;
183 const std::string &responseName,
186 json = Json::parse(body,
nullptr,
false);
190 "malformed " + responseName +
" response");
193const std::string kStructuredStagePrefix =
"xrdclhttp.tape.stage:";
195bool IsValidRequestId(
const std::string &requestId)
197 return !requestId.empty()
198 && requestId.find_first_of(
"\r\n") == std::string::npos;
204 const int requested =
static_cast<int>(flags);
205 const int supported =
210 if(requested & ~supported)
213 "HTTP Tape REST prepare supports stage, cancel, and evict only");
224 "HTTP Tape REST prepare supports stage, cancel, and evict only");
229 "HTTP Tape REST prepare expects exactly one operation flag");
235 const std::vector<std::string> &fileList,
236 std::vector<StageFileSpec> &files)
241 "stage requires at least one file");
245 files.reserve(fileList.size());
246 for(
const auto &file : fileList)
248 if(file.compare(0, kStructuredStagePrefix.size(),
249 kStructuredStagePrefix) == 0)
251 const Json
json = Json::parse(
252 file.substr(kStructuredStagePrefix.size()),
nullptr,
false);
253 if(
json.is_discarded())
256 "malformed structured tape stage entry");
258 if(!
json.is_object())
261 "structured tape stage entry must be a JSON object");
265 if(
json.contains(
"url"))
267 if(!
json[
"url"].is_string())
270 "structured tape stage entry url must be a string");
272 entry.url =
json[
"url"].get<std::string>();
274 if(
json.contains(
"path"))
276 if(!
json[
"path"].is_string())
279 "structured tape stage entry path must be a string");
281 entry.path =
json[
"path"].get<std::string>();
283 if(entry.url.empty() && entry.path.empty())
286 "structured tape stage entry requires url or path");
288 if(
json.contains(
"diskLifetime"))
290 if(!
json[
"diskLifetime"].is_string())
293 "structured tape stage entry diskLifetime must be a string");
295 entry.diskLifetime =
json[
"diskLifetime"].get<std::string>();
297 if(
json.contains(
"targetedMetadata"))
299 if(!
json[
"targetedMetadata"].is_object())
302 "structured tape stage entry targetedMetadata must be a JSON object");
304 entry.targetedMetadata =
json[
"targetedMetadata"];
306 files.push_back(entry);
312 files.push_back(entry);
320 const std::string &message)
322 const unsigned status = statusCode < 0 ? 0 : statusCode;
331bool PathFromInput(
const std::string &input, std::string &path,
336 error =
"empty path";
339 if(input.front() ==
'/')
341 path = NormalizeTapePath(input);
345 std::string endpoint;
346 return UrlEndpointAndPath(input, endpoint, path, error);
349Json PathsRequestBody(
const std::vector<std::string> &paths)
352 body[
"paths"] = paths;
357 const std::string &endpoint,
358 const std::string &resource,
359 const std::vector<std::string> &paths)
363 request.
body = PathsRequestBody(paths).dump();
367 std::vector<std::string> &paths)
370 paths.reserve(inputs.size());
371 for(
const auto &input : inputs)
375 if(!PathFromInput(input, path, error))
379 paths.push_back(path);
384Json StageRequestBody(
const std::vector<StageFileSpec> &files,
388 body[
"files"] = Json::array();
389 for(
const auto &file : files)
391 const std::string input = file.path.empty() ? file.url : file.path;
394 error =
"stage file is missing both URL and path";
399 if(!PathFromInput(input, path, error))
return Json();
403 if(!file.diskLifetime.empty()) item[
"diskLifetime"] = file.diskLifetime;
404 if(!file.targetedMetadata.is_null())
405 item[
"targetedMetadata"] = file.targetedMetadata;
406 body[
"files"].push_back(item);
411bool CopyOptionalTimestamp(
const Json &input,
const char *key,
412 Json &output,
bool &present)
415 const auto item = input.find(key);
416 if(item == input.end())
return true;
417 if(!item->is_number_integer())
return false;
419 std::uint64_t timestamp = 0;
420 if(item->is_number_unsigned())
422 timestamp = item->get<std::uint64_t>();
426 const std::int64_t signedTimestamp = item->get<std::int64_t>();
427 if(signedTimestamp < 0)
return false;
428 timestamp =
static_cast<std::uint64_t
>(signedTimestamp);
430 if(timestamp >
static_cast<std::uint64_t
>(
431 std::numeric_limits<std::time_t>::max()))
436 output[key] =
static_cast<std::time_t
>(timestamp);
442 const Json &input, Json &output)
444 output = Json::object();
445 const auto pathItem = input.find(
"path");
446 if(pathItem == input.end() || !pathItem->is_string()
447 || pathItem->get_ref<
const std::string &>().empty())
450 "stage request response contains a file entry without a non-empty "
453 const auto &path = pathItem->get_ref<
const std::string &>();
454 output[
"path"] = NormalizeTapePath(path);
455 const bool hasOnDisk = input.contains(
"onDisk");
456 const bool hasState = input.contains(
"state");
457 if(hasOnDisk && !input[
"onDisk"].is_boolean())
460 "stage request response contains a non-boolean onDisk field");
462 if(hasState && !input[
"state"].is_string())
465 "stage request response contains a non-string state field");
467 if(hasOnDisk && hasState)
470 "stage request response contains both onDisk and state");
474 output[
"onDisk"] = input[
"onDisk"];
476 if(hasState && !input[
"state"].get_ref<const std::string &>().empty())
478 output[
"state"] = input[
"state"];
480 const auto errorItem = input.find(
"error");
481 if(errorItem != input.end())
483 if(!errorItem->is_string())
486 "stage request response contains a non-string error field");
488 const auto &error = errorItem->get_ref<
const std::string &>();
489 if(!error.empty()) output[
"error"] = error;
491 bool hasStartedAt =
false;
492 bool hasFinishedAt =
false;
493 if(!CopyOptionalTimestamp(input,
"startedAt", output, hasStartedAt)
494 || !CopyOptionalTimestamp(input,
"finishedAt", output, hasFinishedAt))
497 "stage request response contains a non-integer file timestamp");
499 if(!hasState && (hasStartedAt || hasFinishedAt))
502 "stage request response contains file timestamps without state");
510 output = Json::object();
511 if(!input.is_object())
514 "stage request response is not a JSON object");
516 if(!input.contains(
"id") || !input[
"id"].is_string())
519 "stage request response does not contain a string id");
521 if(input[
"id"].get_ref<const std::string &>().empty())
524 "stage request response contains an empty id");
526 if(!input.contains(
"files") || !input[
"files"].is_array())
529 "stage request response does not contain a files array");
532 output[
"id"] = input[
"id"];
533 bool hasCreatedAt =
false;
534 bool hasStartedAt =
false;
535 bool hasCompletedAt =
false;
536 if(!CopyOptionalTimestamp(input,
"createdAt", output, hasCreatedAt)
537 || !CopyOptionalTimestamp(input,
"startedAt", output, hasStartedAt)
538 || !CopyOptionalTimestamp(input,
"completedAt", output,
542 "stage request response contains a non-integer timestamp");
544 if(!hasCreatedAt || !hasStartedAt)
547 "stage request response is missing createdAt or startedAt");
550 output[
"files"] = Json::array();
551 for(
const auto &file : input[
"files"])
553 if(!file.is_object())
556 "stage request response contains a non-object file entry");
560 NormalizeStageFileStatusJson(file, normalizedFile);
561 if(!fileStatusResult.
IsOK())
return fileStatusResult;
562 output[
"files"].push_back(std::move(normalizedFile));
568 const std::string &body,
569 const std::string &operation)
571 return HttpErrorStatus(statusCode,
572 operation +
" failed: " + FormatProblemResponse(statusCode, body));
576 const std::string &body,
577 const std::string &operation)
579 if(statusCode < 200 || statusCode >= 300)
581 return UnexpectedStatus(statusCode, body, operation);
586std::string CanonicalLocality(
const std::string &locality)
588 for(
const auto name : kTapeLocalities)
590 if(strcasecmp(locality.c_str(), name.data()) == 0)
591 return std::string(name);
596Json ArchiveInfoToJson(
const Json *item,
const std::string &url,
597 const std::string &path)
601 result[
"path"] = path;
605 result[
"error"] =
"missing response item for path=" + path;
609 if(item->contains(
"error"))
611 if((*item)[
"error"].is_string())
613 const std::string error = (*item)[
"error"].get<std::string>();
614 if(error.empty()) result[
"locality"] =
"UNKNOWN";
615 else result[
"error"] = error;
619 result[
"error"] =
"error field is not a string";
624 const auto localityItem = item->find(
"locality");
625 if(localityItem == item->end() || !localityItem->is_string()
626 || localityItem->get_ref<
const std::string &>().empty())
628 result[
"error"] =
"locality attribute missing";
632 const auto &locality = localityItem->get_ref<
const std::string &>();
633 result[
"locality"] = CanonicalLocality(locality);
637const Json *FindArchiveInfoItem(
const Json &response,
const std::string &path)
639 if(!response.is_array())
return nullptr;
641 for(
const auto &item : response)
643 if(!item.is_object() || !item.contains(
"path") || !item[
"path"].is_string())
647 if(NormalizeTapePath(item[
"path"].get<std::string>()) == path)
694 TapeEndpoint &endpoint)
698 ParseJsonResponse(body,
"discovery",
json);
699 if(!status.
IsOK())
return status;
701 if(!
json.contains(
"sitename") || !
json[
"sitename"].is_string())
704 "discovery response does not contain a string sitename");
706 if(!
json.contains(
"endpoints") || !
json[
"endpoints"].is_array())
709 "discovery response does not contain an endpoints array");
712 TapeEndpoint selected;
713 int selectedVersion = -1;
714 for(
const auto &candidate :
json[
"endpoints"])
716 if(!candidate.is_object()
717 || !candidate.contains(
"uri") || !candidate[
"uri"].is_string()
718 || !candidate.contains(
"version")
719 || !candidate[
"version"].is_string())
724 const std::string version = candidate[
"version"].get<std::string>();
725 std::string_view versionNumber(version);
726 if(!versionNumber.empty()
727 && (versionNumber.front() ==
'v' || versionNumber.front() ==
'V'))
729 versionNumber.remove_prefix(1);
732 int parsedVersion = -1;
737 catch(
const std::invalid_argument &)
741 catch(
const std::out_of_range &)
745 if(parsedVersion > 1 || parsedVersion < selectedVersion)
750 const std::string uri = candidate[
"uri"].get<std::string>();
751 if(!IsHttpEndpointUri(uri))
continue;
753 selectedVersion = parsedVersion;
755 selected.version = version;
758 if(selected.uri.empty())
761 "discovery response does not advertise a supported v0/v1 endpoint");
764 selected.sitename =
json[
"sitename"].get<std::string>();
770 const std::vector<std::string> &urls,
771 std::vector<std::string> &paths)
778 std::string firstEndpoint;
780 paths.reserve(urls.size());
781 for(
const auto &url : urls)
783 std::string endpoint;
786 if(!UrlEndpointAndPath(url, endpoint, path, error))
790 if(firstEndpoint.empty()) firstEndpoint = endpoint;
791 else if(endpoint != firstEndpoint)
794 "archiveinfo URLs must belong to the same storage endpoint");
796 paths.push_back(path);
798 return XrdCl::XRootDStatus();
801 XrdCl::XRootDStatus ParseArchiveInfoResponse(
802 const std::string &body,
803 const std::vector<std::string> &urls,
804 const std::vector<std::string> &paths,
808 XrdCl::XRootDStatus status =
809 ParseJsonResponse(body,
"archiveinfo",
json);
810 if(!status.
IsOK())
return status;
814 "archiveinfo response is not a JSON array");
817 Json response = Json::array();
818 for(std::size_t index = 0; index < paths.size(); ++index)
820 response.push_back(ArchiveInfoToJson(
821 FindArchiveInfoItem(
json, paths[index]), urls[index], paths[index]));
823 result = response.dump();
824 return XrdCl::XRootDStatus();
830 const std::string &url,
831 const std::vector<std::string> &fileList,
833 pImpl(std::make_unique<
Impl>())
835 pImpl->storageUrl = url;
836 pImpl->initialStatus = ValidateTapePrepareFlags(flags);
837 if(!pImpl->initialStatus.IsOK())
return;
842 pImpl->initialStatus = PrepareStageFiles(fileList, pImpl->stageFiles);
846 if(fileList.size() < 2)
849 "cancel and evict require a request id and at least one path");
853 pImpl->requestId = fileList.front();
854 if(!IsValidRequestId(pImpl->requestId))
857 "cancel and evict require a non-empty request id");
860 pImpl->initialStatus = PathsFromInputs(
861 std::vector<std::string>(fileList.begin() + 1, fileList.end()),
863 if(!pImpl->initialStatus.IsOK())
return;
869 const std::string &url,
872 pImpl(std::make_unique<
Impl>())
874 pImpl->storageUrl = url;
875 std::vector<std::string> args;
879 if(args.size() != 1 || !IsValidRequestId(args.front()))
882 "prepare query expects a single request id");
886 pImpl->requestId = args.front();
893 "unsupported HTTP query");
896 if(args.empty() || args.front().find(
'\r') != std::string::npos)
899 "missing opaque query command");
903 if(args.front() ==
"tape.discover")
908 if(args.front() ==
"tape.stage_delete")
910 if(args.size() != 2 || !IsValidRequestId(args[1]))
913 "tape.stage_delete expects a request id");
917 pImpl->requestId = args[1];
920 if(args.front() ==
"tape.archiveinfo")
925 "tape.archiveinfo expects non-empty URLs");
928 pImpl->urls.assign(args.begin() + 1, args.end());
929 for(
const auto &archiveUrl : pImpl->urls)
931 if(archiveUrl.find(
'\r') != std::string::npos)
934 "tape.archiveinfo expects non-empty URLs");
938 pImpl->initialStatus = ValidateArchiveUrls(pImpl->urls, pImpl->paths);
944 "unsupported HTTP opaque query");
951 if(!pImpl->initialStatus.IsOK())
return pImpl->initialStatus;
955 "unsupported HTTP Tape REST operation");
958 std::string endpoint;
961 if(!UrlEndpointAndPath(pImpl->storageUrl, endpoint, path, error))
968 endpoint,
"/.well-known/wlcg-tape-rest-api");
976 const std::string &body,
978 std::string &response,
986 if(statusCode != 200)
989 return UnexpectedStatus(statusCode, body,
"Tape REST discovery");
992 TapeEndpoint endpoint;
1002 result[
"uri"] = endpoint.uri;
1003 result[
"version"] = endpoint.version;
1004 result[
"sitename"] = endpoint.sitename;
1005 response = result.dump();
1012 const std::string encodedRequestId = pImpl->requestId.empty()
1013 ?
"" : PercentEncodeUrlPathSegment(pImpl->requestId, curl);
1019 Json stageBody = StageRequestBody(pImpl->stageFiles, error);
1027 request.
body = stageBody.dump();
1032 endpoint.uri,
"/stage/" + encodedRequestId);
1035 SetPathsPostRequest(request, endpoint.uri,
1036 "/stage/" + encodedRequestId +
"/cancel", pImpl->paths);
1041 endpoint.uri,
"/stage/" + encodedRequestId);
1044 SetPathsPostRequest(request, endpoint.uri,
1045 "/release/" + encodedRequestId, pImpl->paths);
1048 SetPathsPostRequest(request, endpoint.uri,
"/archiveinfo",
1055 "invalid Tape REST operation state");
1065 "invalid Tape REST response state");
1074 if(statusCode != 201)
1075 return UnexpectedStatus(statusCode, body,
"stage request submission");
1078 ParseJsonResponse(body,
"stage submission",
json);
1079 if(!status.
IsOK())
return status;
1080 if(!
json.contains(
"requestId") || !
json[
"requestId"].is_string()
1081 ||
json[
"requestId"].get_ref<
const std::string &>().empty())
1084 "stage submission response does not contain a non-empty string "
1087 response =
json[
"requestId"].get<std::string>();
1092 if(statusCode != 200)
1093 return UnexpectedStatus(statusCode, body,
"stage request polling");
1096 ParseJsonResponse(body,
"stage polling",
json);
1097 if(!status.
IsOK())
return status;
1098 Json normalizedStatus;
1099 status = NormalizeStageStatusJson(
json, normalizedStatus);
1100 if(status.
IsOK()) response = normalizedStatus.dump();
1104 return EmptyResponseStatus(statusCode, body,
1105 "stage request cancellation");
1107 return EmptyResponseStatus(statusCode, body,
"stage request deletion");
1109 return EmptyResponseStatus(statusCode, body,
"stage request release");
1111 if(statusCode != 200)
1112 return UnexpectedStatus(statusCode, body,
"archiveinfo call");
1113 return ParseArchiveInfoResponse(body, pImpl->urls, pImpl->paths,
1118 "invalid Tape REST operation state");
1125 return FormatProblemResponse(statusCode, body);
XrdCl::XRootDStatus Advance(CURL *curl, long statusCode, const std::string &body, TapeHttpRequest &request, std::string &response, bool &complete)
TapeOperation(const std::string &url, const std::vector< std::string > &fileList, XrdCl::PrepareFlags::Flags flags)
XrdCl::XRootDStatus Start(TapeHttpRequest &request)
Binary blob representation.
std::string ToString() const
Convert the buffer to a string.
static void splitString(Container &result, const std::string &input, const std::string &delimiter)
Split a string.
static uint8_t touint8_t(const std::string_view sv)
static std::string NormalizePath(const std::string &path)
static std::string JoinUrl(const std::string &base, const std::string &path)
std::pair< uint16_t, uint32_t > HTTPStatusConvert(unsigned status)
bool HTTPStatusIsError(unsigned status)
std::string TapeProblemResponse(long statusCode, const std::string &body)
const uint16_t errErrorResponse
const uint16_t errNotImplemented
Operation is not implemented.
const uint16_t stError
An error occurred that could potentially be retried.
const uint16_t errInternal
Internal error.
const uint16_t errInvalidResponse
const uint16_t errInvalidArgs
const uint16_t errNotSupported
std::vector< std::string > paths
XrdCl::XRootDStatus initialStatus
std::vector< std::string > urls
std::vector< StageFileSpec > stageFiles
@ Cancel
cancel staging request
Code
XRootD query request codes.
@ Opaque
Implementation dependent.
@ Prepare
Query prepare status.
bool IsOK() const
We're fine.