Skip to content

Commit b05d9a3

Browse files
authored
124 feat support non json content types in write attachmentsread attachments (#125)
* add support for base64 attachement * test add and get base64 attachement string * changelog
1 parent 9e4e077 commit b05d9a3

4 files changed

Lines changed: 125 additions & 38 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## Unreleased
99

10+
### Added
11+
12+
- Support non-JSON content types in `WriteAttachments` and `ReadAttachments`, [PR-125](https://github.com/reductstore/reduct-cpp/pull/125)
13+
1014
## 1.19.1 - 2026-04-21
1115

1216
### Fixed

src/reduct/bucket.cc

Lines changed: 74 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@
1010
#endif
1111

1212
#include <nlohmann/json.hpp>
13+
#include <openssl/evp.h>
1314

15+
#include <algorithm>
1416
#include <atomic>
1517
#include <cctype>
1618
#include <chrono>
@@ -34,6 +36,43 @@ using internal::IHttpClient;
3436
using internal::ParseStatus;
3537
using internal::QueryOptionsToJsonString;
3638

39+
namespace {
40+
41+
std::string Base64Encode(const std::string& input) {
42+
const auto encoded_len = 4 * ((input.size() + 2) / 3);
43+
std::string output(encoded_len + 1, '\0');
44+
EVP_EncodeBlock(reinterpret_cast<unsigned char*>(output.data()), reinterpret_cast<const unsigned char*>(input.data()),
45+
static_cast<int>(input.size()));
46+
output.resize(encoded_len);
47+
return output;
48+
}
49+
50+
std::string Base64Decode(const std::string& input) {
51+
const auto max_decoded_len = 3 * input.size() / 4;
52+
std::string output(max_decoded_len, '\0');
53+
int decoded_len =
54+
EVP_DecodeBlock(reinterpret_cast<unsigned char*>(output.data()),
55+
reinterpret_cast<const unsigned char*>(input.data()), static_cast<int>(input.size()));
56+
// EVP_DecodeBlock doesn't account for padding, trim trailing zeros
57+
if (input.size() >= 2 && input[input.size() - 1] == '=') decoded_len--;
58+
if (input.size() >= 2 && input[input.size() - 2] == '=') decoded_len--;
59+
output.resize(decoded_len);
60+
return output;
61+
}
62+
63+
bool IsJsonContentType(std::string_view content_type) {
64+
auto pos = content_type.find(';');
65+
auto ct = content_type.substr(0, pos);
66+
while (!ct.empty() && ct.back() == ' ') ct.remove_suffix(1);
67+
while (!ct.empty() && ct.front() == ' ') ct.remove_prefix(1);
68+
std::string lower(ct);
69+
std::transform(lower.begin(), lower.end(), lower.begin(), ::tolower);
70+
return lower == "application/json" || lower == "text/json" ||
71+
(lower.size() >= 5 && lower.substr(lower.size() - 5) == "+json");
72+
}
73+
74+
} // namespace
75+
3776
class Bucket : public IBucket {
3877
using BatchType = internal::BatchType;
3978

@@ -182,22 +221,31 @@ class Bucket : public IBucket {
182221
return ProcessBatchV2(std::move(callback), BatchType::kUpdate);
183222
}
184223

185-
Error WriteAttachments(std::string_view entry_name,
186-
const AttachmentMap& attachments) const noexcept override {
224+
Error WriteAttachments(std::string_view entry_name, const AttachmentMap& attachments,
225+
std::string_view content_type) const noexcept override {
187226
if (attachments.empty()) {
188227
return Error::kOk;
189228
}
190229

230+
const auto ct = content_type.empty() ? "application/json" : std::string(content_type);
231+
const bool is_json = IsJsonContentType(ct);
232+
191233
Batch batch;
192234
const auto meta_entry = fmt::format("{}/$meta", entry_name);
193235
auto timestamp = std::chrono::time_point_cast<std::chrono::microseconds>(Time::clock::now());
194236
for (const auto& [key, payload] : attachments) {
195-
try {
196-
[[maybe_unused]] auto parsed = nlohmann::json::parse(payload);
197-
} catch (const std::exception& ex) {
198-
return Error{.code = -1, .message = ex.what()};
237+
std::string data;
238+
if (is_json) {
239+
try {
240+
[[maybe_unused]] auto parsed = nlohmann::json::parse(payload);
241+
} catch (const std::exception& ex) {
242+
return Error{.code = -1, .message = ex.what()};
243+
}
244+
data = payload;
245+
} else {
246+
data = Base64Decode(payload);
199247
}
200-
batch.AddRecord(meta_entry, timestamp, payload, "application/json", {{"key", key}});
248+
batch.AddRecord(meta_entry, timestamp, data, ct, {{"key", key}});
201249
timestamp += std::chrono::microseconds(1);
202250
}
203251

@@ -226,14 +274,17 @@ class Bucket : public IBucket {
226274
return false;
227275
}
228276

229-
try {
230-
[[maybe_unused]] auto parsed = nlohmann::json::parse(payload);
231-
} catch (const std::exception& ex) {
232-
callback_err = Error{.code = -1, .message = ex.what()};
233-
return false;
277+
if (IsJsonContentType(record.content_type)) {
278+
try {
279+
[[maybe_unused]] auto parsed = nlohmann::json::parse(payload);
280+
} catch (const std::exception& ex) {
281+
callback_err = Error{.code = -1, .message = ex.what()};
282+
return false;
283+
}
284+
attachments[key->second] = std::move(payload);
285+
} else {
286+
attachments[key->second] = Base64Encode(payload);
234287
}
235-
236-
attachments[key->second] = std::move(payload);
237288
return true;
238289
});
239290

@@ -275,13 +326,13 @@ class Bucket : public IBucket {
275326

276327
Batch remove_batch;
277328
const auto meta_entry = fmt::format("{}/$meta", entry_name);
278-
auto query_err = QueryV2({meta_entry}, std::nullopt, std::nullopt, std::move(options),
279-
[&remove_batch](const auto& record) {
280-
auto labels = record.labels;
281-
labels["remove"] = "true";
282-
remove_batch.AddOnlyLabels(record.entry, record.timestamp, std::move(labels));
283-
return true;
284-
});
329+
auto query_err =
330+
QueryV2({meta_entry}, std::nullopt, std::nullopt, std::move(options), [&remove_batch](const auto& record) {
331+
auto labels = record.labels;
332+
labels["remove"] = "true";
333+
remove_batch.AddOnlyLabels(record.entry, record.timestamp, std::move(labels));
334+
return true;
335+
});
285336

286337
if (query_err) {
287338
return query_err;
@@ -846,9 +897,8 @@ class Bucket : public IBucket {
846897
if (internal::IsCompatible("1.19", api_version) && !options.record_entry.has_value()) {
847898
return {{},
848899
Error{.code = -1,
849-
.message =
850-
"record entry and timestamp must be provided for ReductStore API v1.19+; use "
851-
"record_entry and record_timestamp"}};
900+
.message = "record entry and timestamp must be provided for ReductStore API v1.19+; use "
901+
"record_entry and record_timestamp"}};
852902
}
853903

854904
return {std::move(options), Error::kOk};

src/reduct/bucket.h

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -380,8 +380,8 @@ class IBucket {
380380
* @param attachments map of attachment keys to JSON payloads
381381
* @return HTTP or communication error
382382
*/
383-
virtual Error WriteAttachments(std::string_view entry_name,
384-
const AttachmentMap& attachments) const noexcept = 0;
383+
virtual Error WriteAttachments(std::string_view entry_name, const AttachmentMap& attachments,
384+
std::string_view content_type = "") const noexcept = 0;
385385

386386
/**
387387
* @brief Read JSON attachments for an entry
@@ -400,19 +400,19 @@ class IBucket {
400400
* @param attachment_keys attachment keys to remove
401401
* @return HTTP or communication error
402402
*/
403-
virtual Error RemoveAttachments(std::string_view entry_name, const std::set<std::string>& attachment_keys) const
404-
noexcept = 0;
403+
virtual Error RemoveAttachments(std::string_view entry_name,
404+
const std::set<std::string>& attachment_keys) const noexcept = 0;
405405

406406
/**
407407
* Query options
408408
*/
409409
struct QueryOptions {
410-
std::optional<std::string> when; ///< query condition
411-
std::optional<bool> strict; ///< strict mode
412-
std::optional<std::string> ext; ///< additional parameters for extensions
410+
std::optional<std::string> when; ///< query condition
411+
std::optional<bool> strict; ///< strict mode
412+
std::optional<std::string> ext; ///< additional parameters for extensions
413413
std::optional<std::chrono::milliseconds> ttl; ///< time to live
414-
bool continuous = false; ///< continuous query. If true,
415-
/// the method returns the latest record and waits for the next one
414+
bool continuous = false; ///< continuous query. If true,
415+
/// the method returns the latest record and waits for the next one
416416
std::chrono::milliseconds poll_interval = std::chrono::milliseconds(1000); ///< poll interval for continuous query
417417
bool head_only = false; ///< read only metadata
418418
};
@@ -543,9 +543,9 @@ class IBucket {
543543
std::optional<Time> start;
544544
std::optional<Time> stop;
545545
QueryOptions query_options = {};
546-
uint64_t record_index = 0; // legacy index selector (API < 1.19)
547-
std::optional<std::string> record_entry = std::nullopt; // explicit record entry (API >= 1.19)
548-
std::optional<Time> record_timestamp = std::nullopt; // explicit record timestamp (API >= 1.19)
546+
uint64_t record_index = 0; // legacy index selector (API < 1.19)
547+
std::optional<std::string> record_entry = std::nullopt; // explicit record entry (API >= 1.19)
548+
std::optional<Time> record_timestamp = std::nullopt; // explicit record timestamp (API >= 1.19)
549549
std::optional<Time> expire_at = std::nullopt;
550550
std::optional<std::string> file_name = std::nullopt;
551551
std::optional<std::string> base_url = std::nullopt;

tests/reduct/entry_api_test.cc

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
// Copyright 2022-2024 ReductSoftware UG
22

33
#include <catch2/catch.hpp>
4+
#include <nlohmann/json.hpp>
45

56
#include <fstream>
67
#include <map>
7-
#include <nlohmann/json.hpp>
88
#include <set>
99
#include <sstream>
1010
#include <vector>
@@ -782,7 +782,6 @@ TEST_CASE("reduct::IBucket should remove entry attachments with numeric keys", "
782782
REQUIRE(stored_after.empty());
783783
}
784784

785-
786785
TEST_CASE("reduct::IBucket should remove entry attachments with reserved keys", "[entry_api][1_19]") {
787786
Fixture ctx;
788787
auto [bucket, _] = ctx.client->CreateBucket(kBucketName);
@@ -804,6 +803,40 @@ TEST_CASE("reduct::IBucket should remove entry attachments with reserved keys",
804803
REQUIRE(nlohmann::json::parse(stored.at("meta-1")) == nlohmann::json::parse(attachments.at("meta-1")));
805804
}
806805

806+
TEST_CASE("reduct::IBucket should write and read non-JSON attachments", "[entry_api][1_19]") {
807+
Fixture ctx;
808+
auto [bucket, _] = ctx.client->CreateBucket(kBucketName);
809+
REQUIRE(bucket);
810+
811+
// "3q2+7w==" is base64 for bytes {0xDE, 0xAD, 0xBE, 0xEF}
812+
IBucket::AttachmentMap attachments{
813+
{"binary-data", "3q2+7w=="},
814+
};
815+
816+
REQUIRE(bucket->WriteAttachments("entry-1", attachments, "application/octet-stream") == Error::kOk);
817+
818+
auto [stored, err] = bucket->ReadAttachments("entry-1");
819+
REQUIRE(err == Error::kOk);
820+
REQUIRE(stored.size() == 1);
821+
REQUIRE(stored.at("binary-data") == "3q2+7w==");
822+
}
823+
824+
TEST_CASE("reduct::IBucket should write and read JSON attachments with default content type", "[entry_api][1_19]") {
825+
Fixture ctx;
826+
auto [bucket, _] = ctx.client->CreateBucket(kBucketName);
827+
REQUIRE(bucket);
828+
829+
IBucket::AttachmentMap attachments{
830+
{"meta-1", R"({"enabled":true,"values":[1,2,3]})"},
831+
};
832+
833+
REQUIRE(bucket->WriteAttachments("entry-1", attachments) == Error::kOk);
834+
835+
auto [stored, err] = bucket->ReadAttachments("entry-1");
836+
REQUIRE(err == Error::kOk);
837+
REQUIRE(stored.size() == 1);
838+
REQUIRE(nlohmann::json::parse(stored.at("meta-1")) == nlohmann::json::parse(attachments.at("meta-1")));
839+
}
807840

808841
TEST_CASE("Batch should slice data", "[batch]") {
809842
auto batch = IBucket::Batch();

0 commit comments

Comments
 (0)