#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:
parent
f43f5f0633
commit
a0c61b3d94
8 changed files with 396 additions and 0 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
27
include/oatpp-authkit/repo/ActorContext.hpp
Normal file
27
include/oatpp-authkit/repo/ActorContext.hpp
Normal 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
|
||||
31
include/oatpp-authkit/repo/IHistoryRepository.hpp
Normal file
31
include/oatpp-authkit/repo/IHistoryRepository.hpp
Normal 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
|
||||
32
include/oatpp-authkit/repo/ITemporalEntity.hpp
Normal file
32
include/oatpp-authkit/repo/ITemporalEntity.hpp
Normal 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
|
||||
68
include/oatpp-authkit/repo/Repository.hpp
Normal file
68
include/oatpp-authkit/repo/Repository.hpp
Normal 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
|
||||
33
include/oatpp-authkit/repo/TemporalAt.hpp
Normal file
33
include/oatpp-authkit/repo/TemporalAt.hpp
Normal 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
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
200
test/test_repository_interface.cpp
Normal file
200
test/test_repository_interface.cpp
Normal 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;
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue