blob: 9e15f8fad4229a747351246edab117e74e15f264 [file]
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
#pragma once
#include <pulsar/Result.h>
#include <pulsar/Schema.h>
#include <pulsar/defines.h>
#include <pulsar/st/Error.h>
#include <pulsar/st/Expected.h>
#include <cassert>
#include <concepts>
#include <cstddef>
#include <cstdint>
#include <cstring>
#include <memory>
#include <span>
#include <string>
#include <type_traits>
#include <vector>
namespace pulsar::st {
using pulsar::SchemaInfo;
using pulsar::SchemaType;
/**
* @brief The default value type: a raw, uninterpreted byte payload.
*
* Alias for `std::vector<std::byte>`. A `Schema<Bytes>` (the default schema) passes
* the payload through verbatim in both directions, applying no encoding or schema
* declaration to the broker beyond `SchemaType::BYTES`.
*/
using Bytes = std::vector<std::byte>;
/**
* @brief A non-owning, zero-copy view over raw payload bytes.
*
* Alias for `std::span<const std::byte>` — the zero-copy counterpart of `Bytes`. A
* `Schema<BytesView>` publishes the viewed bytes without copying them (the caller
* must keep them valid until the send completes), and on receive
* `Message<BytesView>::value()` returns a view into the message's own buffer (valid
* for that message's lifetime). Use `Bytes` when you want the SDK to own a copy.
*/
using BytesView = std::span<const std::byte>;
/**
* @brief Constraint identifying a *SerDe* for `T`: a type that can describe `T` to
* the broker and convert it to and from bytes.
*
* A SerDe is any copyable type providing the three const members required below.
* `Schema<T>` is constructible from any value satisfying `SerDeFor<SerDe, T>`,
* which is how the same `T` can be carried by different encodings (JSON, Avro,
* protobuf, or a fully custom codec).
*
* The required members are:
* - `SchemaInfo info() const` — the schema description sent to the broker for
* compatibility checking.
* - `Expected<void> encode(const T&, std::vector<std::byte>& out) const` —
* serializes a value of `T` into @p out (replacing its contents), reporting any
* failure as an error value. The buffer is supplied by the caller so it can be
* reused across messages, avoiding a per-message allocation.
* - `Expected<T> decode(std::span<const std::byte>) const` — deserializes bytes
* back to `T`, reporting malformed input as an error value.
*
* @tparam S the candidate SerDe type.
* @tparam T the value type the SerDe handles.
*/
// clang-format off
template <typename S, typename T>
concept SerDeFor = requires(const S& serde, const T& value, std::span<const std::byte> data,
std::vector<std::byte>& out) {
{ serde.info() } -> std::convertible_to<SchemaInfo>;
{ serde.encode(value, out) } -> std::convertible_to<Expected<void>>;
{ serde.decode(data) } -> std::convertible_to<Expected<T>>;
};
// clang-format on
/**
* `Schema<T>` is the typed seam of the API: `Producer<T>`, `Consumer<T>` and
* `Message<T>` are thin facades that only ever call `Schema<T>::encode` / `decode`
* (and `info`, sent to the broker for compatibility). It is a lightweight,
* copyable **value** that holds a type-erased *SerDe* — so the same `T` can be
* carried by different encodings, and a producer can be handed any schema.
*
* A SerDe is any copyable type providing three const members:
* SchemaInfo info() const;
* Expected<void> encode(const T& value, std::vector<std::byte>& out) const; // T -> bytes
* Expected<T> decode(std::span<const std::byte> data) const; // bytes -> T
*
* Construct a `Schema<T>` from a SerDe directly, or use a factory:
* - primitives: `Schema<std::string>{}`, `Schema<int64_t>{}`, `Schema<Bytes>{}` (default)
* - `jsonSchema<T>()` — <pulsar/st/JsonSchema.h> (reflect-cpp; no trait)
* - `avroSchema<T>()` — <pulsar/st/AvroSchema.h> (reflect-cpp; no trait)
* - `protobufNativeSchema<T>()` — <pulsar/st/ProtobufNativeSchema.h> (automatic)
* - or a custom SerDe: `Schema<T>(mySerDe)` for full control.
*
* JSON/Avro for a user struct are derived automatically from the struct's fields
* by reflect-cpp; protobuf uses the generated message's own reflection.
*/
template <typename T>
class Schema {
public:
/** @brief The value type carried by this schema. */
using value_type = T;
/**
* @brief Constructs the default schema for `T`.
*
* For a primitive `T` this installs the built-in codec: `Bytes` (the default),
* `BytesView`, `std::string`, `std::int8_t`, `std::int16_t`, `std::int32_t`,
* `std::int64_t`, `float`, or `double`. Integers and floating-point values are
* encoded as fixed-width big-endian, matching the Pulsar wire format for
* primitive schemas.
*
* For any other (non-primitive) `T` this installs an "unset" schema: it reports
* `SchemaType::BYTES` to the broker, but its `encode` and `decode` return an
* `Error` on use. Supply a real schema (`jsonSchema<T>()`, `avroSchema<T>()`,
* `protobufNativeSchema<T>()`, or a custom SerDe) before producing or consuming
* such a `T`.
*/
Schema();
/**
* @brief Constructs a schema from any SerDe value.
*
* Wraps and type-erases @p serde so that this `Schema<T>` forwards `info`,
* `encode` and `decode` to it. Use this to plug in a custom encoding for `T`.
*
* @tparam SerDe a copyable type satisfying `SerDeFor<SerDe, T>`.
* @param serde the SerDe to adopt; taken by value and stored.
*/
template <typename SerDe>
requires(!std::is_same_v<std::decay_t<SerDe>, Schema> && SerDeFor<std::decay_t<SerDe>, T>)
Schema(SerDe serde)
: self_(std::make_shared<Model<std::decay_t<SerDe>>>(std::move(serde))) {}
/**
* @brief Returns the schema description sent to the broker for compatibility.
* @return the `SchemaInfo` (type, name and schema definition) describing `T`.
*/
SchemaInfo info() const { return self_->info(); }
/**
* @brief Serializes a value into a caller-provided byte buffer.
*
* The destination buffer is supplied by the caller and reused across calls, so
* the producer hot path does not allocate a fresh buffer per message.
*
* @param value the value to encode.
* @param out destination buffer; its previous contents are replaced.
* @return success, or an `Error` — including an unset schema (non-primitive `T`
* with no SerDe supplied) or a SerDe-specific encoding failure.
*/
Expected<void> encode(const T& value, std::vector<std::byte>& out) const {
return self_->encode(value, out);
}
/**
* @brief Deserializes wire bytes back into a value of `T`.
* @param data a view over the payload bytes.
* @return the decoded value, or an `Error` if decoding fails — including an unset
* schema (non-primitive `T` with no SerDe supplied) or bytes that are
* malformed or incompatible with the schema.
*/
Expected<T> decode(std::span<const std::byte> data) const { return self_->decode(data); }
private:
struct Concept {
virtual ~Concept() = default;
virtual SchemaInfo info() const = 0;
virtual Expected<void> encode(const T&, std::vector<std::byte>&) const = 0;
virtual Expected<T> decode(std::span<const std::byte>) const = 0;
};
template <typename SerDe>
struct Model final : Concept {
SerDe serde;
explicit Model(SerDe s) : serde(std::move(s)) {}
SchemaInfo info() const override { return serde.info(); }
Expected<void> encode(const T& v, std::vector<std::byte>& out) const override {
return serde.encode(v, out);
}
Expected<T> decode(std::span<const std::byte> d) const override { return serde.decode(d); }
};
std::shared_ptr<const Concept> self_;
};
/// @cond INTERNAL
/// Internal implementation details: built-in primitive codecs and helpers. Not
/// part of the public API.
namespace detail {
inline constexpr const char* kNoSchemaMsg =
"no schema configured for this value type — pass an explicit Schema "
"(jsonSchema/avroSchema/protobufNativeSchema, or a custom SerDe)";
// Pulsar encodes numeric schemas as fixed-width big-endian.
template <typename U>
inline void encodeBigEndian(U value, std::vector<std::byte>& out) {
static_assert(std::is_integral_v<U>, "integral only");
auto u = static_cast<std::make_unsigned_t<U>>(value);
for (std::size_t i = 0; i < sizeof(U); ++i) {
out.push_back(static_cast<std::byte>((u >> (8 * (sizeof(U) - 1 - i))) & 0xFF));
}
}
template <typename U>
inline U decodeBigEndian(std::span<const std::byte> data) {
static_assert(std::is_integral_v<U>, "integral only");
assert(data.size() >= sizeof(U) && "callers validate the payload length before decoding");
std::make_unsigned_t<U> u = 0;
for (std::size_t i = 0; i < sizeof(U); ++i) {
u = (u << 8) | std::to_integer<unsigned char>(data[i]);
}
return static_cast<U>(u);
}
// Built-in SerDe codecs.
struct BytesCodec {
SchemaInfo info() const { return SchemaInfo(SchemaType::BYTES, "BYTES", ""); }
Expected<void> encode(const Bytes& v, std::vector<std::byte>& out) const {
out = v;
return {};
}
Expected<Bytes> decode(std::span<const std::byte> d) const { return Bytes(d.begin(), d.end()); }
};
// Zero-copy raw bytes: decode hands back a view into the message buffer; the
// producer publishes the caller's span without copying (it bypasses encode, which
// is provided here only as an owning fallback).
struct SpanBytesCodec {
SchemaInfo info() const { return SchemaInfo(SchemaType::BYTES, "BYTES", ""); }
Expected<void> encode(BytesView v, std::vector<std::byte>& out) const {
out.assign(v.begin(), v.end());
return {};
}
Expected<BytesView> decode(std::span<const std::byte> d) const { return d; }
};
struct StringCodec {
SchemaInfo info() const { return SchemaInfo(SchemaType::STRING, "STRING", ""); }
Expected<void> encode(const std::string& v, std::vector<std::byte>& out) const {
const auto* p = reinterpret_cast<const std::byte*>(v.data());
out.assign(p, p + v.size());
return {};
}
Expected<std::string> decode(std::span<const std::byte> d) const {
return std::string(reinterpret_cast<const char*>(d.data()), d.size());
}
};
struct Int32Codec {
SchemaInfo info() const { return SchemaInfo(SchemaType::INT32, "INT32", ""); }
Expected<void> encode(std::int32_t v, std::vector<std::byte>& out) const {
out.clear();
encodeBigEndian(v, out);
return {};
}
Expected<std::int32_t> decode(std::span<const std::byte> d) const {
if (d.size() < sizeof(std::int32_t))
return unexpected(pulsar::ResultInvalidMessage, "INT32 payload too short");
return decodeBigEndian<std::int32_t>(d);
}
};
struct Int64Codec {
SchemaInfo info() const { return SchemaInfo(SchemaType::INT64, "INT64", ""); }
Expected<void> encode(std::int64_t v, std::vector<std::byte>& out) const {
out.clear();
encodeBigEndian(v, out);
return {};
}
Expected<std::int64_t> decode(std::span<const std::byte> d) const {
if (d.size() < sizeof(std::int64_t))
return unexpected(pulsar::ResultInvalidMessage, "INT64 payload too short");
return decodeBigEndian<std::int64_t>(d);
}
};
struct DoubleCodec {
SchemaInfo info() const { return SchemaInfo(SchemaType::DOUBLE, "DOUBLE", ""); }
Expected<void> encode(double v, std::vector<std::byte>& out) const {
std::uint64_t bits;
std::memcpy(&bits, &v, sizeof(bits));
out.clear();
encodeBigEndian(static_cast<std::int64_t>(bits), out);
return {};
}
Expected<double> decode(std::span<const std::byte> d) const {
if (d.size() < sizeof(double))
return unexpected(pulsar::ResultInvalidMessage, "DOUBLE payload too short");
auto bits = static_cast<std::uint64_t>(decodeBigEndian<std::int64_t>(d));
double v;
std::memcpy(&v, &bits, sizeof(v));
return v;
}
};
struct FloatCodec {
SchemaInfo info() const { return SchemaInfo(SchemaType::FLOAT, "FLOAT", ""); }
Expected<void> encode(float v, std::vector<std::byte>& out) const {
std::uint32_t bits;
std::memcpy(&bits, &v, sizeof(bits));
out.clear();
encodeBigEndian(static_cast<std::int32_t>(bits), out);
return {};
}
Expected<float> decode(std::span<const std::byte> d) const {
if (d.size() < sizeof(float))
return unexpected(pulsar::ResultInvalidMessage, "FLOAT payload too short");
auto bits = static_cast<std::uint32_t>(decodeBigEndian<std::int32_t>(d));
float v;
std::memcpy(&v, &bits, sizeof(v));
return v;
}
};
struct Int8Codec {
SchemaInfo info() const { return SchemaInfo(SchemaType::INT8, "INT8", ""); }
Expected<void> encode(std::int8_t v, std::vector<std::byte>& out) const {
out.clear();
encodeBigEndian(v, out);
return {};
}
Expected<std::int8_t> decode(std::span<const std::byte> d) const {
if (d.empty()) return unexpected(pulsar::ResultInvalidMessage, "INT8 payload too short");
return decodeBigEndian<std::int8_t>(d);
}
};
struct Int16Codec {
SchemaInfo info() const { return SchemaInfo(SchemaType::INT16, "INT16", ""); }
Expected<void> encode(std::int16_t v, std::vector<std::byte>& out) const {
out.clear();
encodeBigEndian(v, out);
return {};
}
Expected<std::int16_t> decode(std::span<const std::byte> d) const {
if (d.size() < sizeof(std::int16_t))
return unexpected(pulsar::ResultInvalidMessage, "INT16 payload too short");
return decodeBigEndian<std::int16_t>(d);
}
};
template <typename T>
struct UnsetCodec {
SchemaInfo info() const { return SchemaInfo(SchemaType::BYTES, "BYTES", ""); }
Expected<void> encode(const T&, std::vector<std::byte>&) const {
return unexpected(pulsar::ResultInvalidConfiguration, kNoSchemaMsg);
}
Expected<T> decode(std::span<const std::byte>) const {
return unexpected(pulsar::ResultInvalidConfiguration, kNoSchemaMsg);
}
};
} // namespace detail
/// @endcond
template <typename T>
Schema<T>::Schema() {
if constexpr (std::is_same_v<T, Bytes>) {
self_ = std::make_shared<Model<detail::BytesCodec>>(detail::BytesCodec{});
} else if constexpr (std::is_same_v<T, std::string>) {
self_ = std::make_shared<Model<detail::StringCodec>>(detail::StringCodec{});
} else if constexpr (std::is_same_v<T, std::int32_t>) {
self_ = std::make_shared<Model<detail::Int32Codec>>(detail::Int32Codec{});
} else if constexpr (std::is_same_v<T, std::int64_t>) {
self_ = std::make_shared<Model<detail::Int64Codec>>(detail::Int64Codec{});
} else if constexpr (std::is_same_v<T, double>) {
self_ = std::make_shared<Model<detail::DoubleCodec>>(detail::DoubleCodec{});
} else if constexpr (std::is_same_v<T, float>) {
self_ = std::make_shared<Model<detail::FloatCodec>>(detail::FloatCodec{});
} else if constexpr (std::is_same_v<T, std::int8_t>) {
self_ = std::make_shared<Model<detail::Int8Codec>>(detail::Int8Codec{});
} else if constexpr (std::is_same_v<T, std::int16_t>) {
self_ = std::make_shared<Model<detail::Int16Codec>>(detail::Int16Codec{});
} else if constexpr (std::is_same_v<T, BytesView>) {
self_ = std::make_shared<Model<detail::SpanBytesCodec>>(detail::SpanBytesCodec{});
} else {
self_ = std::make_shared<Model<detail::UnsetCodec<T>>>(detail::UnsetCodec<T>{});
}
}
} // namespace pulsar::st