Scaffold backend gateway and integration docs
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
# ADR 0002: Backend Protocol = HTTPS REST + JSON (OpenAPI-First)
|
||||
|
||||
- Status: Accepted
|
||||
- Date: 2026-02-14
|
||||
|
||||
## Context
|
||||
|
||||
`relationship_saver` is offline-first for MVP. Local DB remains the source of truth for core usage. Backend responsibilities are incremental:
|
||||
|
||||
1. account/session management
|
||||
2. sync transport for local mutations and remote updates
|
||||
3. recommendation/signals feed
|
||||
4. future upload/download URL broker
|
||||
|
||||
We need a transport that is cross-platform, testable in Flutter, easy to stub for local development, and contract-driven across mobile/backend teams.
|
||||
|
||||
## Decision
|
||||
|
||||
Use HTTPS REST + JSON with an OpenAPI-first contract.
|
||||
|
||||
- Client HTTP stack: `dio`
|
||||
- Typed models: `freezed` + `json_serializable`
|
||||
- Gateway boundary: `BackendGateway` (transport-agnostic interface)
|
||||
- Runtime implementation: `BackendGatewayRest`
|
||||
- Local development implementation: `BackendGatewayFake`
|
||||
- Auth/session support: access + refresh token with secure storage
|
||||
- Retry policy: only transient failures; non-idempotent retries require idempotency key
|
||||
- Error surface: typed `BackendException` taxonomy
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- Strong, versioned contract artifact in `docs/api/openapi.yaml`
|
||||
- Fast local iteration with fake gateway, no backend dependency for core UX
|
||||
- Explicit behavior for refresh/retry/failure classes
|
||||
- Good compatibility across Android/iOS/macOS/Windows/Linux/Web
|
||||
|
||||
### Tradeoffs
|
||||
|
||||
- REST polling for feed/sync may be less real-time than socket-based push
|
||||
- Some envelope sizes may require tuning and pagination strategy
|
||||
- Requires careful idempotency handling for mutation endpoints
|
||||
|
||||
## Evolution Plan
|
||||
|
||||
Realtime is deferred for now but planned:
|
||||
|
||||
1. Keep REST as source of truth for command/query semantics.
|
||||
2. Add optional SSE or WebSocket channel for push notifications/signals invalidation.
|
||||
3. Continue using same DTO schema envelopes from OpenAPI where possible.
|
||||
4. Add explicit capability negotiation (`/v1/capabilities`) when realtime ships.
|
||||
|
||||
## Rejected Alternatives
|
||||
|
||||
- gRPC-only for MVP: stronger streaming story, but higher setup and broader tooling burden for immediate scope.
|
||||
- GraphQL-first: flexible querying, but sync/mutation idempotency patterns are clearer with explicit REST resources now.
|
||||
@@ -0,0 +1,203 @@
|
||||
# Relationship Saver Progress Log
|
||||
|
||||
Updated: 2026-02-14
|
||||
|
||||
## Current Status
|
||||
|
||||
The Flutter app now includes a production-grade backend integration scaffold with an offline-first posture:
|
||||
|
||||
- Local/offline UX remains primary; backend is optional for core usage.
|
||||
- A transport-agnostic gateway interface is in place.
|
||||
- REST implementation with auth refresh/retry/error mapping is implemented.
|
||||
- Deterministic fake gateway is available for local/dev without backend.
|
||||
- OpenAPI contract + ADR + setup docs + unit tests are in place.
|
||||
- `flutter analyze` and `flutter test` are passing.
|
||||
|
||||
## What Was Implemented
|
||||
|
||||
### 1. Core Config + Theme Shell
|
||||
|
||||
- Added backend config with build-time + runtime override:
|
||||
- `lib/core/config/app_config.dart`
|
||||
- Added lightweight app theme aligned with reference style direction:
|
||||
- `lib/core/config/app_theme.dart`
|
||||
- Updated app entry shell and Riverpod root:
|
||||
- `lib/main.dart`
|
||||
|
||||
### 2. Network Foundation
|
||||
|
||||
Implemented a Dio stack with request metadata, auth, refresh, retry, and error mapping:
|
||||
|
||||
- `lib/core/network/dio_factory.dart`
|
||||
- `lib/core/network/network_constants.dart`
|
||||
- `lib/core/network/backend_exception.dart`
|
||||
- `lib/core/network/dio_error_mapper.dart`
|
||||
- Interceptors:
|
||||
- `lib/core/network/interceptors/request_metadata_interceptor.dart`
|
||||
- `lib/core/network/interceptors/auth_interceptor.dart`
|
||||
- `lib/core/network/interceptors/refresh_token_interceptor.dart`
|
||||
- `lib/core/network/interceptors/retry_interceptor.dart`
|
||||
|
||||
Behavior implemented:
|
||||
|
||||
- Adds per-request `X-Request-Id`.
|
||||
- Adds `Authorization: Bearer <token>` when session exists.
|
||||
- On `401`, attempts one refresh (`/v1/auth/refresh`) then retries original request.
|
||||
- If refresh fails, session is cleared and `AuthExpiredException` surfaced.
|
||||
- Retry policy (max 2 retries, exponential backoff):
|
||||
- Retry transient failures (`timeouts`, `connection`, `5xx`).
|
||||
- Safe methods (`GET/HEAD/OPTIONS`) retry automatically.
|
||||
- Non-idempotent endpoints only retry when `Idempotency-Key` exists.
|
||||
- Maps Dio failures into typed backend exceptions with metadata.
|
||||
|
||||
### 3. Auth Token Storage
|
||||
|
||||
- Interface:
|
||||
- `lib/core/auth/token_store.dart`
|
||||
- Production implementation:
|
||||
- `lib/core/auth/secure_token_store.dart`
|
||||
- Test/dev implementation:
|
||||
- `lib/core/auth/in_memory_token_store.dart`
|
||||
|
||||
### 4. Backend Integration Layer
|
||||
|
||||
Domain boundary and implementations:
|
||||
|
||||
- Interface:
|
||||
- `lib/integrations/backend/backend_gateway.dart`
|
||||
- REST:
|
||||
- `lib/integrations/backend/backend_gateway_rest.dart`
|
||||
- Fake:
|
||||
- `lib/integrations/backend/backend_gateway_fake.dart`
|
||||
- Optional Riverpod providers:
|
||||
- `lib/integrations/backend/backend_gateway_provider.dart`
|
||||
- Sync validation:
|
||||
- `lib/integrations/backend/sync_envelope_validator.dart`
|
||||
|
||||
Implemented methods:
|
||||
|
||||
- sign-in/out + me
|
||||
- sync pull/push (with idempotency key on push)
|
||||
- signals feed + ack
|
||||
- upload init + download URL scaffold
|
||||
|
||||
Offline-first hardening:
|
||||
|
||||
- `signOut()` clears local token store in `finally`, even when network call fails.
|
||||
|
||||
### 5. Typed Models (Freezed + JSON)
|
||||
|
||||
- `lib/integrations/backend/models/backend_models.dart`
|
||||
- Generated files:
|
||||
- `backend_models.freezed.dart`
|
||||
- `backend_models.g.dart`
|
||||
|
||||
Includes DTOs/enums for:
|
||||
|
||||
- Auth (`SignInRequest`, `AuthSession`, refresh request/response, `UserProfile`)
|
||||
- Sync (`ChangeEnvelope`, push/pull requests/results, ack/rejection)
|
||||
- Signals (`SignalsFeed`, `SignalItem`, ack request/action)
|
||||
- Uploads (`UploadInitRequest/Result`, `DownloadUrl`)
|
||||
|
||||
### 6. Contract + Architecture Docs
|
||||
|
||||
- OpenAPI spec:
|
||||
- `docs/api/openapi.yaml`
|
||||
- ADR:
|
||||
- `docs/ADR/0002-backend-protocol-rest-openapi.md`
|
||||
- Setup instructions:
|
||||
- `docs/SETUP.md`
|
||||
- Previous concise checklist log:
|
||||
- `docs/PROGRESS.md`
|
||||
|
||||
### 7. Tooling + Quality
|
||||
|
||||
- Strict analyzer/lints:
|
||||
- `analysis_options.yaml`
|
||||
- Dependencies added in `pubspec.yaml`:
|
||||
- runtime: `dio`, `freezed_annotation`, `json_annotation`, `flutter_secure_storage`, `uuid`, `clock`, `flutter_riverpod`
|
||||
- dev: `freezed`, `json_serializable`, `build_runner`, `mocktail`
|
||||
|
||||
### 8. Test Coverage Added
|
||||
|
||||
Core network and model tests:
|
||||
|
||||
- `test/core/network/token_refresh_interceptor_test.dart`
|
||||
- `test/core/network/dio_error_mapper_test.dart`
|
||||
- `test/core/network/retry_interceptor_test.dart`
|
||||
- `test/integrations/backend/backend_models_serialization_test.dart`
|
||||
- `test/integrations/backend/sync_envelope_validator_test.dart`
|
||||
- helper adapter: `test/helpers/queue_http_client_adapter.dart`
|
||||
- updated smoke widget test: `test/widget_test.dart`
|
||||
|
||||
## Verification Snapshot
|
||||
|
||||
Most recent local checks:
|
||||
|
||||
- `flutter analyze` -> pass
|
||||
- `flutter test` -> pass
|
||||
|
||||
## Known Gaps / Intentional Scope Limits
|
||||
|
||||
- No realtime transport yet (SSE/WebSocket intentionally deferred).
|
||||
- No backend-generated client code from OpenAPI yet.
|
||||
- No persistence layer wiring to local DB yet (gateway is ready to plug in).
|
||||
- No UI features using backend gateway yet beyond app shell readiness.
|
||||
- Fake gateway data is deterministic sample content, not scenario-configurable yet.
|
||||
|
||||
## Handoff Plan For Next Agent Sessions
|
||||
|
||||
### Priority 1: Repository Hygiene + CI
|
||||
|
||||
1. Add CI workflow for `flutter analyze` + `flutter test`.
|
||||
2. Add branch protections / PR template if desired.
|
||||
3. Keep generated files committed policy explicit in README.
|
||||
|
||||
### Priority 2: Local DB Integration (Offline-First Core)
|
||||
|
||||
1. Define local domain entities and repositories for `person`, `capture`, `giftIdea`, `eventIdea`, `tag`, `reminderRule`.
|
||||
2. Map local mutations to `ChangeEnvelope` and enqueue for sync.
|
||||
3. Create sync orchestrator service that:
|
||||
- pushes pending local mutations,
|
||||
- applies pull changes with conflict policy,
|
||||
- stores/advances cursor.
|
||||
4. Add tests for conflict handling and cursor progression.
|
||||
|
||||
### Priority 3: App Wiring + UX
|
||||
|
||||
1. Wire `BackendGateway` into app services/use cases (not UI widgets directly).
|
||||
2. Add session state provider (signed in / expired / signed out).
|
||||
3. Add explicit offline indicators and non-blocking sync status UI.
|
||||
4. Start consuming fake gateway in feature screens before live backend.
|
||||
|
||||
### Priority 4: Security + Hardening
|
||||
|
||||
1. Add central log redaction helper for headers/bodies.
|
||||
2. Add token-expiry pre-check strategy (optional proactive refresh).
|
||||
3. Evaluate secure storage behavior on web target and fallback policy.
|
||||
4. Add tests for concurrent 401 refresh race conditions.
|
||||
|
||||
### Priority 5: API Evolution
|
||||
|
||||
1. Version strategy in OpenAPI (`/v1` already set).
|
||||
2. Define backend error payload schema formally in OpenAPI.
|
||||
3. Add capabilities endpoint for future realtime negotiation.
|
||||
4. Add SSE/WebSocket design ADR when backend is ready.
|
||||
|
||||
## How To Continue Quickly
|
||||
|
||||
1. Run: `flutter pub get`
|
||||
2. If models change: `dart run build_runner build --delete-conflicting-outputs`
|
||||
3. Validate: `flutter analyze && flutter test`
|
||||
4. Start from:
|
||||
- gateway surface: `lib/integrations/backend/backend_gateway.dart`
|
||||
- sync transport: `lib/integrations/backend/backend_gateway_rest.dart`
|
||||
- contract: `docs/api/openapi.yaml`
|
||||
|
||||
## Important Notes
|
||||
|
||||
- Current default base URL is placeholder (`https://api.example.com`).
|
||||
- Use fake mode for local iteration:
|
||||
- `flutter run --dart-define=USE_FAKE_BACKEND=true`
|
||||
- Build-time URL override:
|
||||
- `flutter run --dart-define=BACKEND_BASE_URL=<your-url>`
|
||||
@@ -0,0 +1,35 @@
|
||||
# Setup
|
||||
|
||||
## Backend Base URL
|
||||
|
||||
Build-time (recommended):
|
||||
|
||||
```bash
|
||||
flutter run --dart-define=BACKEND_BASE_URL=https://api.example.com
|
||||
```
|
||||
|
||||
Runtime override (for future settings screens):
|
||||
|
||||
```dart
|
||||
AppConfig.overrideBackendBaseUrl('https://staging.example.com');
|
||||
```
|
||||
|
||||
## Run With Fake Gateway
|
||||
|
||||
Use fake gateway for local/offline development:
|
||||
|
||||
```bash
|
||||
flutter run --dart-define=USE_FAKE_BACKEND=true
|
||||
```
|
||||
|
||||
## Run Tests
|
||||
|
||||
```bash
|
||||
flutter test
|
||||
```
|
||||
|
||||
## Generate Code
|
||||
|
||||
```bash
|
||||
dart run build_runner build --delete-conflicting-outputs
|
||||
```
|
||||
@@ -0,0 +1,344 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: Relationship Saver API
|
||||
version: 0.1.0
|
||||
description: |
|
||||
Backend contract for auth, sync, signals, and upload scaffolding.
|
||||
servers:
|
||||
- url: https://api.example.com
|
||||
paths:
|
||||
/v1/auth/sign-in:
|
||||
post:
|
||||
summary: Sign in
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SignInRequest'
|
||||
responses:
|
||||
'200':
|
||||
description: Signed-in session
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/AuthSession'
|
||||
/v1/auth/refresh:
|
||||
post:
|
||||
summary: Refresh session
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/AuthRefreshRequest'
|
||||
responses:
|
||||
'200':
|
||||
description: Refreshed token bundle
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/AuthRefreshResponse'
|
||||
/v1/auth/sign-out:
|
||||
post:
|
||||
summary: Sign out
|
||||
responses:
|
||||
'204':
|
||||
description: Signed out
|
||||
/v1/me:
|
||||
get:
|
||||
summary: Current user profile
|
||||
responses:
|
||||
'200':
|
||||
description: Profile
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/UserProfile'
|
||||
/v1/sync/push:
|
||||
post:
|
||||
summary: Push local changes
|
||||
parameters:
|
||||
- in: header
|
||||
name: Idempotency-Key
|
||||
schema:
|
||||
type: string
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SyncPushRequest'
|
||||
responses:
|
||||
'200':
|
||||
description: Push result
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SyncPushResult'
|
||||
/v1/sync/pull:
|
||||
get:
|
||||
summary: Pull remote changes
|
||||
parameters:
|
||||
- in: query
|
||||
name: cursor
|
||||
schema:
|
||||
type: string
|
||||
- in: query
|
||||
name: limit
|
||||
schema:
|
||||
type: integer
|
||||
default: 200
|
||||
responses:
|
||||
'200':
|
||||
description: Pull result
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SyncPullResult'
|
||||
/v1/signals:
|
||||
get:
|
||||
summary: Fetch signals feed
|
||||
parameters:
|
||||
- in: query
|
||||
name: cursor
|
||||
schema:
|
||||
type: string
|
||||
- in: query
|
||||
name: since
|
||||
schema:
|
||||
type: string
|
||||
format: date-time
|
||||
- in: query
|
||||
name: limit
|
||||
schema:
|
||||
type: integer
|
||||
default: 50
|
||||
- in: query
|
||||
name: personId
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: Signals page
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SignalsFeed'
|
||||
/v1/signals/{id}/ack:
|
||||
post:
|
||||
summary: Acknowledge signal
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SignalAckRequest'
|
||||
responses:
|
||||
'204':
|
||||
description: Acknowledged
|
||||
/v1/uploads:
|
||||
post:
|
||||
summary: Initialize upload
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/UploadInitRequest'
|
||||
responses:
|
||||
'200':
|
||||
description: Upload URL
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/UploadInitResult'
|
||||
/v1/files/{fileId}:
|
||||
get:
|
||||
summary: Resolve file download URL
|
||||
parameters:
|
||||
- in: path
|
||||
name: fileId
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: Download URL
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/DownloadUrl'
|
||||
components:
|
||||
schemas:
|
||||
SignInRequest:
|
||||
type: object
|
||||
properties:
|
||||
method:
|
||||
type: string
|
||||
enum: [email_magic_link, password, oidc]
|
||||
email:
|
||||
type: string
|
||||
format: email
|
||||
password:
|
||||
type: string
|
||||
idToken:
|
||||
type: string
|
||||
required: [method]
|
||||
UserProfile:
|
||||
type: object
|
||||
properties:
|
||||
id: { type: string }
|
||||
email: { type: string, format: email }
|
||||
displayName: { type: string }
|
||||
avatarFileId: { type: string }
|
||||
required: [id]
|
||||
AuthSession:
|
||||
type: object
|
||||
properties:
|
||||
accessToken: { type: string }
|
||||
refreshToken: { type: string }
|
||||
expiresAt: { type: string, format: date-time }
|
||||
user:
|
||||
$ref: '#/components/schemas/UserProfile'
|
||||
required: [accessToken, refreshToken, expiresAt, user]
|
||||
AuthRefreshRequest:
|
||||
type: object
|
||||
properties:
|
||||
refreshToken: { type: string }
|
||||
required: [refreshToken]
|
||||
AuthRefreshResponse:
|
||||
type: object
|
||||
properties:
|
||||
accessToken: { type: string }
|
||||
refreshToken: { type: string }
|
||||
expiresAt: { type: string, format: date-time }
|
||||
required: [accessToken, expiresAt]
|
||||
ChangeEnvelope:
|
||||
type: object
|
||||
properties:
|
||||
schemaVersion: { type: integer }
|
||||
entityType: { type: string }
|
||||
entityId: { type: string }
|
||||
op:
|
||||
type: string
|
||||
enum: [upsert, delete]
|
||||
modifiedAt:
|
||||
type: string
|
||||
format: date-time
|
||||
clientMutationId: { type: string }
|
||||
payload:
|
||||
type: object
|
||||
nullable: true
|
||||
additionalProperties: true
|
||||
required:
|
||||
[schemaVersion, entityType, entityId, op, modifiedAt, clientMutationId]
|
||||
SyncPushRequest:
|
||||
type: object
|
||||
properties:
|
||||
deviceId: { type: string }
|
||||
cursor: { type: string }
|
||||
changes:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/ChangeEnvelope'
|
||||
idempotencyKey: { type: string }
|
||||
required: [deviceId, changes, idempotencyKey]
|
||||
MutationAck:
|
||||
type: object
|
||||
properties:
|
||||
clientMutationId: { type: string }
|
||||
serverMutationId: { type: string }
|
||||
acceptedAt: { type: string, format: date-time }
|
||||
required: [clientMutationId]
|
||||
MutationRejection:
|
||||
type: object
|
||||
properties:
|
||||
clientMutationId: { type: string }
|
||||
code: { type: string }
|
||||
message: { type: string }
|
||||
required: [clientMutationId, code, message]
|
||||
SyncPushResult:
|
||||
type: object
|
||||
properties:
|
||||
cursor: { type: string }
|
||||
accepted:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/MutationAck'
|
||||
rejected:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/MutationRejection'
|
||||
required: [cursor, accepted, rejected]
|
||||
SyncPullResult:
|
||||
type: object
|
||||
properties:
|
||||
cursor: { type: string }
|
||||
changes:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/ChangeEnvelope'
|
||||
required: [cursor, changes]
|
||||
SignalItem:
|
||||
type: object
|
||||
properties:
|
||||
id: { type: string }
|
||||
type: { type: string }
|
||||
title: { type: string }
|
||||
description: { type: string }
|
||||
personId: { type: string }
|
||||
createdAt: { type: string, format: date-time }
|
||||
metadata:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
required: [id, type, title, createdAt]
|
||||
SignalsFeed:
|
||||
type: object
|
||||
properties:
|
||||
cursor: { type: string }
|
||||
items:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/SignalItem'
|
||||
required: [items]
|
||||
SignalAckRequest:
|
||||
type: object
|
||||
properties:
|
||||
action:
|
||||
type: string
|
||||
enum: [viewed, dismissed, saved, purchased]
|
||||
at:
|
||||
type: string
|
||||
format: date-time
|
||||
required: [action, at]
|
||||
UploadInitRequest:
|
||||
type: object
|
||||
properties:
|
||||
purpose:
|
||||
type: string
|
||||
enum: [avatar, attachment]
|
||||
contentType: { type: string }
|
||||
sizeBytes: { type: integer }
|
||||
required: [purpose, contentType, sizeBytes]
|
||||
UploadInitResult:
|
||||
type: object
|
||||
properties:
|
||||
uploadUrl: { type: string }
|
||||
fileId: { type: string }
|
||||
expiresAt: { type: string, format: date-time }
|
||||
required: [uploadUrl, fileId, expiresAt]
|
||||
DownloadUrl:
|
||||
type: object
|
||||
properties:
|
||||
downloadUrl: { type: string }
|
||||
expiresAt: { type: string, format: date-time }
|
||||
required: [downloadUrl, expiresAt]
|
||||
Reference in New Issue
Block a user