AnyBookStore lets you scan or manually enter the ISBN of any book — novels, manga, technical literature, comics — to build a personal library with your own ratings and a read/unread status. This repository holds the backend of that ecosystem: a REST API and a background worker. A web frontend and a mobile app (sharing one Avalonia UI codebase, already scaffolded here) are planned on top of this backend.
- The book catalog is free, always. Metadata comes from the Open Library API — no key, no paid tier, no account-gated dependency.
- API requests are always fast. The API never calls Open Library synchronously. A background worker owns all communication with Open Library and keeps the local catalog cache current; the API only ever reads/writes its own Postgres database.
- You never hit a dead end. If a book isn't in the catalog yet, the lookup response says so and invites you to add it yourself. Your own entry is private until it's published — either by you, or automatically once the worker finds an official match on Open Library.
AnyBookStore.Domain Plain entities & enums (Book, Author, Picture, UserBook, PendingLookup, ...)
AnyBookStore.Infrastructure EF Core (Postgres), Open Library client, publish/enrichment services
AnyBookStore.Api ASP.NET Core minimal API (JWT + Google OAuth, book/library endpoints)
AnyBookStore.Worker Background service: resolves the lookup queue, re-syncs stale entries
Domain has no dependency on EF Core or ASP.NET Core, so both the Api and the Worker (and any future backend component) share the same model without pulling in unrelated framework weight.
Book rows are ISBN-keyed and either Private (visible only to their creator) or Published (visible to everyone). An ISBN is only unique among published, non-retired books — several users can independently hold private drafts of the same not-yet-published ISBN without colliding. A private draft becomes canonical either when its owner explicitly publishes it, or automatically once the background worker finds a matching Open Library record; if a published book for that ISBN already exists by then, the draft is retired and its UserBook (library) entries are relinked to the canonical book instead. UserBook is designed for a future offline-sync mobile client: client-generatable IDs, an UpdatedAt sync cursor, soft deletes, and Postgres xmin-based optimistic concurrency exposed via ETag/If-Match.
LookupProcessingWorkerpolls aPendingLookupqueue (populated whenever an ISBN lookup misses the cache) every 10 seconds, with exponential backoff (1h → 6h → 1d → 3d → 7d) on repeated misses.FreshnessSweepWorkerre-checks already-published, Open-Library-sourced catalog entries older than 90 days once a day, so corrections on Open Library's end eventually reach the cache.
.NET 10 · ASP.NET Core Minimal APIs · EF Core + Npgsql (PostgreSQL) · ASP.NET Core Identity (password hashing) + Google OAuth · JWT bearer auth with rotating refresh tokens · prometheus-net.AspNetCore for /metrics · Docker Compose · xUnit + NSubstitute + Shouldly (Testcontainers for the API's integration suite, EF Core InMemory for unit tests) · Avalonia UI + CommunityToolkit.Mvvm for the client (web/desktop/mobile, one codebase)
- .NET 10 SDK
- Docker + Docker Compose
cp .env.example .env # then fill in real values - see below
docker compose up -d --buildThis starts three containers: db (Postgres), api (port 8080), worker. The API applies pending EF Core migrations automatically on startup.
| Variable | Purpose |
|---|---|
POSTGRES_USER / POSTGRES_PASSWORD / POSTGRES_DB |
Postgres credentials, shared by all three containers |
JWT_SIGNING_KEY |
HMAC-SHA256 signing key for access tokens — needs at least 256 bits (openssl rand -base64 32) |
AUTHENTICATION__GOOGLE__CLIENTID / AUTHENTICATION__GOOGLE__CLIENTSECRET |
From a Google Cloud Console "Web application" OAuth client; the authorized redirect URI must be <your-domain>/auth/external/google/callback |
ASP.NET Core validates every registered authentication scheme on the very first incoming request — if the Google credentials are missing or empty, the entire API returns 500 on every route, not just the Google ones. Keep them set even if you don't use Google login yet.
docker compose up -d db # just the database
dotnet run --project AnyBookStore.Api
dotnet run --project AnyBookStore.WorkerLocal defaults (dev-only signing key, localhost connection string) live in each project's appsettings.Development.json. Secrets that shouldn't be committed (Google OAuth credentials) still need to come from the environment — e.g. in fish:
set -gx AUTHENTICATION__GOOGLE__CLIENTID "..."
set -gx AUTHENTICATION__GOOGLE__CLIENTSECRET "..."dotnet run --project AnyBookStore.BrowserOpens a dev server on http://localhost:5235 (see AnyBookStore.Browser/Properties/launchSettings.json). It's an Avalonia app compiled to WebAssembly — the same UI codebase (AnyBookStore/) that will back the desktop and mobile clients, rather than a separate web framework, so views/view models/services are written once. Points at the API via AnyBookStore/Configuration/AppConfig.cs (http://localhost:5087 in Debug builds); the API's WebClient:BaseUrl config must match wherever the client is actually served from, for CORS and the Google OAuth redirect to work.
In development, an interactive reference is available at /scalar/v1. All routes below are relative to the API's base URL (no /api prefix — the API is meant to live on its own subdomain).
| Method | Route | Auth | Purpose |
|---|---|---|---|
| POST | /auth/register |
— | Email/password registration |
| POST | /auth/login |
— | Email/password login |
| POST | /auth/refresh |
— | Rotates a refresh token for a new access token |
| POST | /auth/logout |
✓ | Revokes a refresh token |
| GET | /auth/external/google |
— | Starts the Google OAuth handshake |
| GET | /auth/external/google/complete |
— | Finishes it, returns the same token pair as /login |
| GET | /books/isbn/{isbn} |
optional | Cache-only lookup; on a miss, returns 404 with canCreate: true and queues a background lookup |
| POST | /books |
✓ | Creates a private draft for an ISBN (idempotent) |
| POST | /books/{id}/publish |
✓ | Publishes your own draft |
| GET | /library |
✓ | Lists your active library entries |
| GET | /library/{id} |
✓ | Gets one entry (returns an ETag) |
| POST | /library |
✓ | Adds a book to your library (idempotent) |
| PATCH | /library/{id} |
✓ | Updates rating/read status; requires If-Match, 409 on a stale version |
| DELETE | /library/{id} |
✓ | Soft-deletes an entry |
| GET | /library/changes?since= |
✓ | Sync feed: everything (including deletes) changed after since |
| GET | /metrics |
— | Prometheus scrape endpoint |
dotnet test AnyBookStore.Api.Tests # integration tests, spins up Postgres via Testcontainers
dotnet test AnyBookStore.Infrastructure.Tests # unit tests, EF Core InMemory
dotnet test AnyBookStore.Worker.Tests # unit tests, EF Core InMemoryThe backend (API + worker, auth, catalog, library sync, Docker deployment, tests) is feature-complete per the original plan.
The client is a single Avalonia UI codebase (AnyBookStore/) shared across web (AnyBookStore.Browser, WebAssembly), desktop (AnyBookStore.Desktop) and, eventually, mobile (AnyBookStore.Android/AnyBookStore.iOS) — one source of truth for both the UI and the wire contracts (AnyBookStore.Contracts, referenced by both the client and the API). Auth (email/password + Google OAuth, token refresh, DE/EN localization) is implemented and working end-to-end against the real API on the web target; desktop/mobile still need their own OAuth completion flow (the browser-redirect approach doesn't carry over as-is), and the actual library screens (list/add-by-ISBN/rate/remove) are still a placeholder on every target.