#7: Repository<T> interface set + ITemporalEntity + IHistoryRepository<T>

Header-only foundation for the structural refactor that moves fewo-webapp
from per-entity *Db clients to a shared Repository<TDto> abstraction. This
ships interfaces only — no concrete implementations, no callers updated.

Decisions baked in (all settled in the issue body):
- Mixed entity_id allocation: caller may supply, otherwise the concrete
  repo generates a UUID inside save().
- UnitOfWork / cross-repo transactions: explicitly out of scope.
- Repository<T> is a virtual-method interface, not a C++20 concept.
- History queries live on a separate IHistoryRepository<T> so non-temporal
  repos don't have to implement a stub.

Decorators (TemporalRepository<T>, ScopeGuardRepository<T>) follow in #8;
the optional IQueryable<T> capability for typed filtering follows in #9.
The fewo-webapp Person pilot (uwe.admin/fewo-webapp#457) and the wider
26-entity rollout (uwe.admin/fewo-webapp#458) build on this.

Closes #7

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Uwe Schuster 2026-04-27 22:42:47 +02:00
parent f43f5f0633
commit a0c61b3d94
8 changed files with 396 additions and 0 deletions

View file

@ -13,6 +13,7 @@ hardened auth / security stack. Header-only, oatpp 1.3+, C++17.
| `util/RateLimiter.hpp` | In-memory token-bucket keyed on an arbitrary string (typically the client IP from `clientIpTrusted`). |
| `util/TokenExtract.hpp` | `extractToken` (Cookie/Bearer), `isValidIp` (IPv4/IPv6 via `inet_pton`), `clientIpTrusted` (loopback-gated XFF). |
| `startup/RequireEncryptionKey.hpp` | `requireEncryptionKey(envVarName, encryptionEnabled, allowPlaintext)` — refuse startup without a symmetric key unless a dev flag overrides. |
| `repo/Repository.hpp` + `IHistoryRepository.hpp` + `ITemporalEntity.hpp` + `TemporalAt.hpp` + `ActorContext.hpp` | Pure-abstract `Repository<TDto>` interface set distilled from fewo-webapp's per-entity `*Db` clients. Mixed UUID allocation on `save`, separate `IHistoryRepository<T>` for temporal versions, `ActorContext` placeholder for the upcoming scope-guard decorator. |
## Consume via CMake

View file

@ -0,0 +1,27 @@
#ifndef OATPP_AUTHKIT_REPO_ACTOR_CONTEXT_HPP
#define OATPP_AUTHKIT_REPO_ACTOR_CONTEXT_HPP
#include <string>
#include <vector>
namespace oatpp_authkit::repo {
/**
* @brief Who is performing a repository action, plus what they're scoped to.
*
* Passed to the scope-guard decorator predicate (added in oatpp-authkit#8) so
* resource-level authorisation can be evaluated outside the concrete repo.
*
* Kept deliberately minimal consumers extend by composing this struct into
* a richer per-app context if needed. The fields here are the union of what
* the fewo-webapp property-scope guard needs (user id + a list of allowed
* resource ids) and nothing more.
*/
struct ActorContext {
std::string userId;
std::vector<std::string> allowedScopes; ///< Opaque ids; consumer decides their meaning (property ids, tenant ids, …).
};
} // namespace oatpp_authkit::repo
#endif

View file

@ -0,0 +1,31 @@
#ifndef OATPP_AUTHKIT_REPO_I_HISTORY_REPOSITORY_HPP
#define OATPP_AUTHKIT_REPO_I_HISTORY_REPOSITORY_HPP
#include "oatpp/core/Types.hpp"
namespace oatpp_authkit::repo {
/**
* @brief All historical versions for a temporal entity.
*
* Kept separate from `Repository<T>` deliberately non-temporal repos
* (caches, lookup tables, anything without `valid_from` / `valid_until`)
* don't have a meaningful answer to `history()` and shouldn't be forced
* to implement a stub. The temporal decorator in oatpp-authkit#8 is the
* canonical implementer.
*
* Returns versions ordered ascending by `valid_from`, oldest first. An
* empty vector means the entity id was never seen.
*/
template <class TDto>
class IHistoryRepository {
public:
virtual ~IHistoryRepository() = default;
virtual oatpp::Vector<oatpp::Object<TDto>>
history(const oatpp::String& entityId) = 0;
};
} // namespace oatpp_authkit::repo
#endif

View file

@ -0,0 +1,32 @@
#ifndef OATPP_AUTHKIT_REPO_I_TEMPORAL_ENTITY_HPP
#define OATPP_AUTHKIT_REPO_I_TEMPORAL_ENTITY_HPP
namespace oatpp_authkit::repo {
/**
* @brief Marker for DTOs that carry temporal-versioning columns.
*
* A DTO opts in by inheriting this empty marker, signalling to the temporal
* decorator (oatpp-authkit#8) that the DTO has the three required oatpp
* `String` fields:
*
* @code
* DTO_FIELD(String, entity_id); // stable across versions
* DTO_FIELD(String, valid_from); // ISO-8601 UTC
* DTO_FIELD(String, valid_until); // ISO-8601 UTC; '9999-12-31T23:59:59Z' = live
* @endcode
*
* The marker is intentionally a plain empty struct rather than a C++20
* concept the issue explicitly chose "pure abstract interface, not
* `requires` clause" for the wider Repository<T> shape, and this matches.
*
* Because oatpp DTOs use macros (`DTO_INIT` / `DTO_FIELD`), the field-shape
* contract above is documentation-enforced, not compiler-enforced. The
* temporal decorator dynamic-casts and accesses fields by name; a missing
* field surfaces as a clean runtime error at decorator construction.
*/
struct ITemporalEntity {};
} // namespace oatpp_authkit::repo
#endif

View file

@ -0,0 +1,68 @@
#ifndef OATPP_AUTHKIT_REPO_REPOSITORY_HPP
#define OATPP_AUTHKIT_REPO_REPOSITORY_HPP
#include "oatpp/core/Types.hpp"
namespace oatpp_authkit::repo {
/**
* @brief Pure-abstract per-DTO repository interface.
*
* Generic on `TDto`, temporal-agnostic. Concrete adapters wrap an
* `oatpp::orm::DbClient` (or any other store) and implement the four
* methods below. Cross-cutting concerns (temporal versioning, scope
* authorisation) are added by stacking decorators from oatpp-authkit#8
* around the concrete adapter at construction time.
*
* @section semantics Method semantics
*
* - `findByEntityId(entityId)` Single live row matching `entity_id`.
* Returns null `oatpp::Object` when not found. The decorator that adds
* point-in-time reads exposes a different method (`findByEntityId(id, at)`)
* on its own surface; the abstract here stays narrow.
*
* - `list()` All live rows for this entity type, no filtering. Filtered
* reads land in the optional `IQueryable<T>` capability tracked by
* oatpp-authkit#9; do not bake filter predicates into this base interface.
*
* - `save(dto)` Mixed `entity_id` allocation:
* - If `dto->entity_id` is null on entry, the implementation generates
* a fresh UUID and writes it back to the DTO before persisting.
* - If `dto->entity_id` is non-null, it is used as-is.
* No upsert semantics are implied at this layer the temporal decorator
* in oatpp-authkit#8 turns "save" into a versioning insert; without that
* decorator the concrete repo decides whether `save` is insert-or-update
* on its own.
*
* - `softDelete(entityId)` Marks the row removed without erasing history.
* Concrete repos typically set a `deleted_at` column or its equivalent.
*
* @section design Design decisions (all settled in the issue body)
*
* 1. `entity_id` allocation is mixed (caller may supply or leave null).
* 2. UnitOfWork / cross-repo transactions are explicitly out of scope.
* 3. `Repository<T>` is a virtual-method interface, not a C++20 concept.
* 4. History queries live on a separate `IHistoryRepository<T>` so non-
* temporal repos don't have to implement them.
*/
template <class TDto>
class Repository {
public:
virtual ~Repository() = default;
/** @brief Single live row by stable entity id. Null oatpp::Object when not found. */
virtual oatpp::Object<TDto> findByEntityId(const oatpp::String& entityId) = 0;
/** @brief All live rows for this entity type (no filtering at this layer). */
virtual oatpp::Vector<oatpp::Object<TDto>> list() = 0;
/** @brief Persist DTO; allocate UUID for `entity_id` if null on entry. */
virtual void save(const oatpp::Object<TDto>& dto) = 0;
/** @brief Mark the row removed without erasing it. */
virtual void softDelete(const oatpp::String& entityId) = 0;
};
} // namespace oatpp_authkit::repo
#endif

View file

@ -0,0 +1,33 @@
#ifndef OATPP_AUTHKIT_REPO_TEMPORAL_AT_HPP
#define OATPP_AUTHKIT_REPO_TEMPORAL_AT_HPP
#include <cstdint>
namespace oatpp_authkit::repo {
/**
* @brief Point-in-time selector for temporal reads.
*
* Concrete repositories implementing temporal versioning use this to
* choose between "live" (`valid_until = sentinel`) and "as-of a specific
* timestamp" reads. The interface is decoupled from any particular clock
* type; consumers pass milliseconds-since-epoch.
*/
struct TemporalAt {
enum class Kind { Live, At };
Kind kind{Kind::Live};
int64_t timestamp{0}; ///< Milliseconds since epoch; only meaningful when kind == At.
static TemporalAt live() {
return TemporalAt{Kind::Live, 0};
}
static TemporalAt at(int64_t ts) {
return TemporalAt{Kind::At, ts};
}
};
} // namespace oatpp_authkit::repo
#endif

View file

@ -21,3 +21,7 @@ add_test(NAME security_headers COMMAND test_security_headers)
add_executable(test_json_serialization test_json_serialization.cpp)
target_link_libraries(test_json_serialization PRIVATE oatpp::authkit oatpp::oatpp)
add_test(NAME json_serialization COMMAND test_json_serialization)
add_executable(test_repository_interface test_repository_interface.cpp)
target_link_libraries(test_repository_interface PRIVATE oatpp::authkit oatpp::oatpp)
add_test(NAME repository_interface COMMAND test_repository_interface)

View file

@ -0,0 +1,200 @@
// Tests for the oatpp-authkit#7 Repository<T> interface set. Exercises the
// contract through a trivial in-memory fake — confirms the abstract methods
// compile against an oatpp DTO, the mixed-id allocation branch on save() is
// implementable, and findByEntityId/list/softDelete round-trip as documented.
//
// No SQL involvement — that's the concrete adapters' job (out of scope).
#include "oatpp-authkit/repo/Repository.hpp"
#include "oatpp-authkit/repo/IHistoryRepository.hpp"
#include "oatpp-authkit/repo/ITemporalEntity.hpp"
#include "oatpp-authkit/repo/TemporalAt.hpp"
#include "oatpp-authkit/repo/ActorContext.hpp"
#include "oatpp/core/macro/codegen.hpp"
#include "oatpp/core/Types.hpp"
#include <cstdio>
#include <random>
#include <unordered_map>
#include OATPP_CODEGEN_BEGIN(DTO)
namespace {
class MockDto : public oatpp::DTO {
DTO_INIT(MockDto, DTO)
DTO_FIELD(String, entity_id);
DTO_FIELD(String, name);
};
#include OATPP_CODEGEN_END(DTO)
int g_failures = 0;
#define REQUIRE(expr) do { \
if (!(expr)) { \
std::fprintf(stderr, "FAIL %s:%d %s\n", __FILE__, __LINE__, #expr); \
++g_failures; \
} \
} while (0)
// Trivial UUID-ish generator — sufficient for the in-memory fake; concrete
// adapters can use libuuid or similar in production.
std::string generateId() {
static std::mt19937_64 rng{std::random_device{}()};
char buf[33];
std::snprintf(buf, sizeof(buf), "%016llx%016llx",
(unsigned long long)rng(), (unsigned long long)rng());
return std::string(buf);
}
class InMemoryRepo : public oatpp_authkit::repo::Repository<MockDto> {
std::unordered_map<std::string, oatpp::Object<MockDto>> live;
std::unordered_map<std::string, oatpp::Object<MockDto>> deleted;
public:
oatpp::Object<MockDto> findByEntityId(const oatpp::String& id) override {
auto it = live.find(*id);
return it == live.end() ? nullptr : it->second;
}
oatpp::Vector<oatpp::Object<MockDto>> list() override {
auto v = oatpp::Vector<oatpp::Object<MockDto>>::createShared();
for (auto& kv : live) v->push_back(kv.second);
return v;
}
void save(const oatpp::Object<MockDto>& dto) override {
if (!dto->entity_id) {
dto->entity_id = generateId();
}
live[*dto->entity_id] = dto;
}
void softDelete(const oatpp::String& id) override {
auto it = live.find(*id);
if (it != live.end()) {
deleted[*id] = it->second;
live.erase(it);
}
}
};
void test_save_allocates_uuid_when_id_null() {
InMemoryRepo repo;
auto dto = MockDto::createShared();
dto->name = oatpp::String("alice");
REQUIRE(!dto->entity_id); // precondition: id is null
repo.save(dto);
REQUIRE(dto->entity_id); // id was filled in
REQUIRE(std::string(*dto->entity_id).size() > 0);
}
void test_save_uses_supplied_id_when_present() {
InMemoryRepo repo;
auto dto = MockDto::createShared();
dto->entity_id = oatpp::String("supplied-id-42");
dto->name = oatpp::String("bob");
repo.save(dto);
REQUIRE(std::string(*dto->entity_id) == "supplied-id-42");
auto loaded = repo.findByEntityId(oatpp::String("supplied-id-42"));
REQUIRE(loaded);
REQUIRE(std::string(*loaded->name) == "bob");
}
void test_find_by_entity_id_round_trip() {
InMemoryRepo repo;
auto dto = MockDto::createShared();
dto->entity_id = oatpp::String("abc");
dto->name = oatpp::String("carol");
repo.save(dto);
auto found = repo.findByEntityId(oatpp::String("abc"));
REQUIRE(found);
REQUIRE(std::string(*found->name) == "carol");
auto missing = repo.findByEntityId(oatpp::String("does-not-exist"));
REQUIRE(!missing);
}
void test_list_returns_all_live_rows() {
InMemoryRepo repo;
for (const char* n : {"a", "b", "c"}) {
auto dto = MockDto::createShared();
dto->entity_id = oatpp::String(n);
dto->name = oatpp::String(n);
repo.save(dto);
}
auto all = repo.list();
REQUIRE(all->size() == 3);
}
void test_soft_delete_removes_from_live_view() {
InMemoryRepo repo;
auto dto = MockDto::createShared();
dto->entity_id = oatpp::String("delete-me");
dto->name = oatpp::String("doomed");
repo.save(dto);
REQUIRE(repo.findByEntityId(oatpp::String("delete-me")));
repo.softDelete(oatpp::String("delete-me"));
REQUIRE(!repo.findByEntityId(oatpp::String("delete-me")));
REQUIRE(repo.list()->size() == 0);
}
void test_temporal_at_value_type() {
using oatpp_authkit::repo::TemporalAt;
auto live = TemporalAt::live();
REQUIRE(live.kind == TemporalAt::Kind::Live);
auto pin = TemporalAt::at(1700000000000LL);
REQUIRE(pin.kind == TemporalAt::Kind::At);
REQUIRE(pin.timestamp == 1700000000000LL);
}
void test_actor_context_minimal() {
oatpp_authkit::repo::ActorContext ctx;
ctx.userId = "user-1";
ctx.allowedScopes = {"prop-A", "prop-B"};
REQUIRE(ctx.userId == "user-1");
REQUIRE(ctx.allowedScopes.size() == 2);
}
// Compile-time check: ITemporalEntity is usable as a base marker.
#include OATPP_CODEGEN_BEGIN(DTO)
class TemporalDto : public oatpp::DTO, public oatpp_authkit::repo::ITemporalEntity {
DTO_INIT(TemporalDto, DTO)
DTO_FIELD(String, entity_id);
DTO_FIELD(String, valid_from);
DTO_FIELD(String, valid_until);
};
#include OATPP_CODEGEN_END(DTO)
void test_temporal_marker_compiles() {
auto dto = TemporalDto::createShared();
dto->entity_id = oatpp::String("t");
REQUIRE(std::string(*dto->entity_id) == "t");
}
} // namespace
int main() {
test_save_allocates_uuid_when_id_null();
test_save_uses_supplied_id_when_present();
test_find_by_entity_id_round_trip();
test_list_returns_all_live_rows();
test_soft_delete_removes_from_live_view();
test_temporal_at_value_type();
test_actor_context_minimal();
test_temporal_marker_compiles();
std::printf("%s (%d failures)\n", g_failures ? "FAIL" : "OK", g_failures);
return g_failures ? 1 : 0;
}