docs(claude): update CLAUDE.md to match current codebase
- Fix stale fragment rendering example (into_request → into_request_and_search) - Correct domain conversion convention to to_domain() and fold into Repository Pattern - Add listing.rs and missing ID types to architecture tree - Document define_enriched_get_handler! macro - Add Route Module Structure pattern (load/page/fragment trio, build_page_view) - Note submission types vs direct domain structs in FlexiblePayload
This commit is contained in:
parent
546e105b10
commit
fa0cccade0
1 changed files with 47 additions and 18 deletions
65
CLAUDE.md
65
CLAUDE.md
|
|
@ -56,9 +56,10 @@ The codebase follows **Clean Architecture / Domain-Driven Design** with four lay
|
||||||
src/
|
src/
|
||||||
├── domain/ # Pure business logic, no external dependencies
|
├── domain/ # Pure business logic, no external dependencies
|
||||||
│ ├── errors.rs # RepositoryError enum
|
│ ├── errors.rs # RepositoryError enum
|
||||||
│ ├── ids.rs # Typed ID wrappers (RoasterId, RoastId, BagId)
|
│ ├── ids.rs # Typed ID wrappers (RoasterId, RoastId, BagId, BrewId, GearId, etc.)
|
||||||
|
│ ├── listing.rs # Pagination & sorting (SortKey, ListRequest, Page, PageSize)
|
||||||
│ ├── repositories.rs # Repository traits
|
│ ├── repositories.rs # Repository traits
|
||||||
│ └── {entity}.rs # Entity definitions (roasters, roasts, bags, etc.)
|
│ └── {entity}.rs # Entity definitions (roasters, roasts, bags, brews, gear, etc.)
|
||||||
│
|
│
|
||||||
├── infrastructure/ # External integrations (database, HTTP client)
|
├── infrastructure/ # External integrations (database, HTTP client)
|
||||||
│ ├── repositories/ # SQL implementations of repository traits
|
│ ├── repositories/ # SQL implementations of repository traits
|
||||||
|
|
@ -91,7 +92,13 @@ pub trait RoasterRepository {
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
SQL implementations live in `infrastructure/repositories/`.
|
SQL implementations live in `infrastructure/repositories/`. Each uses a private `Record` struct (e.g., `BagRecord`) with a `to_domain()` method to convert from database row to domain entity:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
impl BagRecord {
|
||||||
|
fn to_domain(self) -> Bag { ... }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
### Typed IDs
|
### Typed IDs
|
||||||
|
|
||||||
|
|
@ -180,11 +187,11 @@ When a Datastar request is detected, return a fragment instead of a full page:
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
pub(crate) async fn roasters_page(...) -> Result<Response, StatusCode> {
|
pub(crate) async fn roasters_page(...) -> Result<Response, StatusCode> {
|
||||||
let request = query.into_request::<RoasterSortKey>();
|
let (request, search) = query.into_request_and_search::<RoasterSortKey>();
|
||||||
|
|
||||||
if is_datastar_request(&headers) {
|
if is_datastar_request(&headers) {
|
||||||
// Datastar request → return fragment only
|
// Datastar request → return fragment only
|
||||||
return render_roaster_list_fragment(state, request, is_authenticated).await;
|
return render_roaster_list_fragment(state, request, search, is_authenticated).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Traditional request → return full page with layout
|
// Traditional request → return full page with layout
|
||||||
|
|
@ -254,11 +261,12 @@ navigator.sort_href(key) // "/roasters?sort=name&dir=..."
|
||||||
|
|
||||||
#### Flexible Payload Handling
|
#### Flexible Payload Handling
|
||||||
|
|
||||||
Handlers accept both JSON and form data via `FlexiblePayload<T>`:
|
Handlers accept both JSON and form data via `FlexiblePayload<T>`. When form fields don't map directly to the domain `New*` struct (e.g., the form sends a roaster name that needs to be resolved to an ID), use a `*Submission` newtype that handles the conversion:
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
pub(crate) async fn create_roaster(
|
pub(crate) async fn create_roaster(
|
||||||
payload: FlexiblePayload<NewRoaster>,
|
payload: FlexiblePayload<NewRoaster>, // simple — form maps 1:1 to domain
|
||||||
|
// or: FlexiblePayload<NewBrewSubmission> // submission type — needs conversion
|
||||||
) -> Result<Response, ApiError> {
|
) -> Result<Response, ApiError> {
|
||||||
let (new_roaster, source) = payload.into_parts();
|
let (new_roaster, source) = payload.into_parts();
|
||||||
|
|
||||||
|
|
@ -277,11 +285,14 @@ pub(crate) async fn create_roaster(
|
||||||
For simple get/delete API handlers, use the macros in `application/routes/macros.rs`:
|
For simple get/delete API handlers, use the macros in `application/routes/macros.rs`:
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
use super::macros::{define_get_handler, define_delete_handler};
|
use super::macros::{define_get_handler, define_enriched_get_handler, define_delete_handler};
|
||||||
|
|
||||||
// GET /api/v1/roasters/:id → returns JSON
|
// GET /api/v1/roasters/:id → returns JSON
|
||||||
define_get_handler!(get_roaster, RoasterId, Roaster, roaster_repo);
|
define_get_handler!(get_roaster, RoasterId, Roaster, roaster_repo);
|
||||||
|
|
||||||
|
// GET with enriched data (joins related entities) → returns JSON
|
||||||
|
define_enriched_get_handler!(get_roast, RoastId, RoastWithRoaster, roast_repo, get_with_roaster);
|
||||||
|
|
||||||
// DELETE /api/v1/roasters/:id → returns fragment for Datastar or 204 for API
|
// DELETE /api/v1/roasters/:id → returns fragment for Datastar or 204 for API
|
||||||
define_delete_handler!(
|
define_delete_handler!(
|
||||||
delete_roaster,
|
delete_roaster,
|
||||||
|
|
@ -292,22 +303,40 @@ define_delete_handler!(
|
||||||
);
|
);
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Route Module Structure
|
||||||
|
|
||||||
|
Each list-bearing route module (roasters, roasts, bags, gear, brews) follows the same internal structure:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// Path constants for full-page and fragment URLs
|
||||||
|
const ROASTER_PAGE_PATH: &str = "/roasters";
|
||||||
|
const ROASTER_FRAGMENT_PATH: &str = "/roasters#roaster-list";
|
||||||
|
|
||||||
|
// Data loader — calls repo.list() and builds view models via build_page_view()
|
||||||
|
async fn load_roaster_page(state, request, search)
|
||||||
|
-> Result<(Paginated<RoasterView>, ListNavigator<RoasterSortKey>), AppError>
|
||||||
|
|
||||||
|
// Page handler — checks is_datastar_request(), returns full page or fragment
|
||||||
|
pub(crate) async fn roasters_page(...) -> Result<Response, StatusCode>
|
||||||
|
|
||||||
|
// Fragment renderer — returns just the list partial for Datastar replacement
|
||||||
|
async fn render_roaster_list_fragment(state, request, search, is_authenticated)
|
||||||
|
-> Result<Response, AppError>
|
||||||
|
```
|
||||||
|
|
||||||
|
The `build_page_view()` helper in `application/routes/support.rs` standardises the conversion from a repo `Page<T>` to `(Paginated<V>, ListNavigator<K>)`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let (items, navigator) = build_page_view(page, request, RoasterView::from,
|
||||||
|
ROASTER_PAGE_PATH, ROASTER_FRAGMENT_PATH, search);
|
||||||
|
```
|
||||||
|
|
||||||
### Error Handling
|
### Error Handling
|
||||||
|
|
||||||
- Domain errors: `RepositoryError` in `domain/errors.rs`
|
- Domain errors: `RepositoryError` in `domain/errors.rs`
|
||||||
- HTTP errors: `AppError` in `application/errors.rs` with proper status code mapping
|
- HTTP errors: `AppError` in `application/errors.rs` with proper status code mapping
|
||||||
- CLI errors: Use `anyhow::Result` for simplicity
|
- CLI errors: Use `anyhow::Result` for simplicity
|
||||||
|
|
||||||
### Domain Conversion
|
|
||||||
|
|
||||||
Records from the database should have an `into_domain()` method or equivalent:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
impl BagRecord {
|
|
||||||
fn into_domain(self) -> Bag { ... }
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Table & List Patterns
|
## Table & List Patterns
|
||||||
|
|
||||||
### Template Structure
|
### Template Structure
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue