Skip to content

feat(api): let users read and set their preferred language - #582

Merged
abdulrafey1 merged 5 commits into
mainfrom
refey/feat/lang-04-api
Aug 12, 2026
Merged

feat(api): let users read and set their preferred language#582
abdulrafey1 merged 5 commits into
mainfrom
refey/feat/lang-04-api

Conversation

@abdulrafey1

@abdulrafey1 abdulrafey1 commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

What

Exposes the preferred-language setting over the API so clients can list the supported languages and set or clear a user's choice.

Changes

  • feat(api): add GET /api/v1/languages, returning the supported languages and the platform default. Unauthenticated — the static-UI translation layer has to render the login, register and password-reset pages, which have no token yet
  • feat(api): add PATCH /api/v1/user/me to set or clear the preference, and add language to the user schema
  • chore(frontend): regenerate the API client types

How to Test

  1. uv run pytest tests/api/v1/test_language.py tests/api/v1/test_user.py -q — 18 tests pass.
  2. curl http://localhost:7727/api/v1/languages returns the three languages and "default": "en" with no token.
  3. curl -X PATCH .../api/v1/user/me -d '{"language":"es"}' returns 200; GET /api/v1/user/me then reports "language": "es".
  4. -d '{"language":"en-US"}' returns 422 and leaves the column untouched — matching is exact and case-sensitive.
  5. -d '{"language":null}' clears the preference back to null.

Notes

No migration. GET /me returns the raw stored value, null included, so a client can tell "never chose" from "chose English"; resolving to the default is resolve_language's job.

GET /api/v1/languages is the only endpoint outside auth that requires no token. It serves a compile-time constant — the supported-language table and the platform default — so there is no user data, no database read, and nothing to enumerate. Gating it would force the frontend to carry the duplicate allowlist the endpoint exists to remove.

language is a required field on the request body, so an explicit null clears the preference while omitting the field is a 422 rather than a silent no-op.

Generated content does not consult this preference yet — that arrives with prompt injection in a later change.

This description was written with the assistance of an LLM (Claude).

@abdulrafey1 abdulrafey1 self-assigned this Aug 10, 2026
@abdulrafey1
abdulrafey1 requested a review from hamza-56 August 10, 2026 06:48
@abdulrafey1
abdulrafey1 force-pushed the refey/feat/lang-04-api branch from e64b2f9 to 25c83ce Compare August 10, 2026 07:24
@abdulrafey1
abdulrafey1 force-pushed the refey/feat/lang-04-api branch from 160b507 to b94564b Compare August 10, 2026 09:26
Comment thread sparkth/api/v1/language/routes.py Outdated
router = APIRouter()


@router.get("", response_model=SupportedLanguages, dependencies=[Depends(get_current_user)])

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The auth gate blocks the second consumer named in this module docstring: if the static-UI translation layer reads this endpoint, the login, register and password-reset pages cannot fetch the list because there is no token yet. The payload is a constant, non-sensitive allowlist, so either drop the gate or the frontend needs a duplicated client-side list, which is what this endpoint exists to avoid.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gate dropped in 39d2c19. You were right that the docstring named a consumer the code blocked; the payload is a compile-time constant, so there was nothing to protect.

  • routes.pydependencies=[Depends(get_current_user)] removed, and the docstring now states why it is public rather than leaving the next reader to wonder.
  • test_requires_authenticationtest_does_not_require_authentication, asserting 200 with no token. Written first and watched fail with assert 401 == 200 before the gate came off.
  • docs: document the preferred language setting #583 is stacked on this branch and documented the Bearer header; updated there in f0004b3. The /user/me examples keep theirs.
  • PR body's test step and Notes updated.

One thing worth flagging since it is a first for this codebase: every other endpoint under /api/v1 outside auth is gated, by get_current_user or a permission. This is now the only deliberate exception, so it is called out explicitly in the module docstring and the PR notes rather than left to be discovered.

generated.ts is unchanged — the security removal does not alter the typed surface, so the test.frontend.api staleness check stays green.

Comment thread sparkth/api/v1/user/schemas.py Outdated
from sparkth.schemas import UserBase


class User(UserBase):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This move contradicts the rule this same PR adds to architectural_patterns.md: models shared across domains stay in the root sparkth/schemas.py. User is shared, auth.py imports it for the register and login responses, so auth.py now imports the user package, which executes user/init.py and user/routes.py at import time. Any future import of auth.py from the user routes becomes a circular import. Either keep the model in sparkth/schemas.py or document why it is an exception.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moved back to sparkth/schemas.py in 39d2c19. The contradiction was real: User is shared by the rule's own definition — auth.py returns it from register and login, user/routes.py from /user/me — so it belongs in the root alongside UserBase and Token.

  • class User back in sparkth/schemas.py; auth.py and user/routes.py both import it from there.
  • UserLanguageUpdate stays in user/schemas.py — nothing outside those routes sends it, so it is genuinely owned by the package. That is the split the rule describes.
  • Verified the chain is gone: auth.UserSchema.__module__ is now sparkth.schemas, so importing auth no longer executes user/__init__.py and therefore user/routes.py.

On the cycle: confirmed latent rather than live — user/routes.py imports fastapi, sqlmodel, core.models.user, lib.auth, lib.db and lib.permissions, but not api.v1.auth, so nothing was broken today. It would have failed the first time someone imported in that direction, which is the worst kind of landmine.

Also amended the rule itself, since "shared" was doing a lot of unstated work: it now names User, says ownership is decided by who imports the model rather than which routes feel closest to it, and spells out the __init__.pyroutes.py execution that makes the pull-into-a-package version circular. Same class of mistake should not be inviting next time.

Full suite green: 1726 passed, 3 skipped; ruff, format and mypy strict clean.

@abdulrafey1
abdulrafey1 force-pushed the refey/feat/lang-04-api branch from b94564b to be207de Compare August 11, 2026 23:23
@abdulrafey1
abdulrafey1 force-pushed the refey/feat/lang-04-api branch from be207de to 02a3db5 Compare August 11, 2026 23:31
Base automatically changed from refey/feat/lang-03-user-column to main August 12, 2026 06:36
abdulrafey1 and others added 4 commits August 12, 2026 11:36
The frontend picker and the static-UI translation layer both need this list;
serving it keeps the allowlist defined once instead of duplicated client-side.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
PATCH /user/me validates the tag against the supported-language allowlist in the
request model, so an unsupported value is a 422 before any domain logic runs. GET
returns the raw column, null included, so the frontend can tell a user who never
chose from one who chose English.

PATCH loads the row it is about to write with session.get rather than mutating the
injected principal. get_current_user resolves the same request-scoped session, so in
production that is an identity-map hit; the guard exists because a principal that is
not attached to the request session would otherwise make commit() a silent no-op
while the endpoint still answered 200. Such a principal is now a 404. updated_at
carries a default_factory but no onupdate, so the write bumps it explicitly.

The test double for get_current_user takes the same session dependency the real one
does, so a value one request writes is visible to a later request on the same client.
That rewrote _override_current_user — the shared helper the three pre-existing
is_admin tests also call — from a detached make_transient snapshot to a live
select(User) on the request session; those three tests are themselves left unmodified
and still pass.

Also covers the PATCH auth gate (401 unauthenticated), that a PATCH only ever writes
to the caller's own row, that a principal with no row is refused rather than handed a
200 for a write that never happened, and that a successful PATCH advances updated_at.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
openapi.json is a gitignored build artifact and is not committed; only the
generated TypeScript client changes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Records when an api/v1 module is a single file and when it is a package with
routes.py and schemas.py, now that user/ and language/ join permissions/ and
whitelist/ in the second shape while five modules remain single files.

Covers what belongs in each file, why __init__.py re-exporting router is a
deliberate exception to the avoid-re-exports rule, that domain exception -> HTTP
mappings register there, and that route paths come from the prefix in api.py so
converting a file to a package never moves a URL.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@abdulrafey1
abdulrafey1 force-pushed the refey/feat/lang-04-api branch from 02a3db5 to 84eadd9 Compare August 12, 2026 06:37
…ma shared

Two review findings on #582.

The languages endpoint was gated, but its own docstring names the static-UI translation
layer as a consumer, and that layer renders the login, register and password-reset pages,
which have no token yet. Gating it there would force the frontend to carry the duplicate
allowlist this endpoint exists to remove. What it serves is a compile-time constant, so
there is no user data, no database read, and nothing to enumerate. It is now the only
endpoint outside auth that is unauthenticated, and says so.

The User response model moved into sparkth/api/v1/user/schemas.py, contradicting the rule
this same branch adds: models shared across domains stay in the root sparkth/schemas.py.
User is shared — auth returns it from register and login — and the move made auth import
the user package, which executes user/__init__.py and so user/routes.py, leaving any later
import of auth from those routes a circular one. Moved back, with UserLanguageUpdate
staying put since nothing outside those routes sends it. The rule now names User and
explains what ownership is decided by, so the next reader does not repeat the move.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
abdulrafey1 added a commit that referenced this pull request Aug 12, 2026
Follows the endpoint dropping its auth gate on #582: documenting a Bearer header the
endpoint no longer wants would send readers looking for a token before they can render a
sign-in page in the right language. The /user/me examples keep theirs — those do need one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@abdulrafey1
abdulrafey1 merged commit 2b37ead into main Aug 12, 2026
8 checks passed
@abdulrafey1
abdulrafey1 deleted the refey/feat/lang-04-api branch August 12, 2026 08:29
abdulrafey1 added a commit that referenced this pull request Aug 12, 2026
Follows the endpoint dropping its auth gate on #582: documenting a Bearer header the
endpoint no longer wants would send readers looking for a token before they can render a
sign-in page in the right language. The /user/me examples keep theirs — those do need one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
abdulrafey1 added a commit that referenced this pull request Aug 12, 2026
* docs: document the preferred language setting

Covers the supported list, how to read and set a user's choice, what null means,
and what happens to a stored tag once its language leaves the allowlist. The guide
is explicit that generated content does not consume the preference yet, so nothing
here promises behaviour the platform does not have. The DEFAULT_LANGUAGE reference
entry ships with the setting itself, so this change is guide-only.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* docs: the language list is fetched without a token

Follows the endpoint dropping its auth gate on #582: documenting a Bearer header the
endpoint no longer wants would send readers looking for a token before they can render a
sign-in page in the right language. The /user/me examples keep theirs — those do need one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
abdulrafey1 added a commit that referenced this pull request Aug 12, 2026
#582 and #583 landed on main as squash commits while this branch carried their original
commits, so git saw the same files added on both sides and flagged six conflicts where
there was one real question: which copy is current.

main's is. Its copies include the review fixes made after this branch was cut — the
languages endpoint dropped its auth gate, and the User schema moved back to the root
sparkth/schemas.py — so every file this branch does not own is resolved to main's version,
and the branch keeps only what it exists to change: the api_router assembly moving from
sparkth/api/v1/api.py into the package __init__.

Two of those needed more than a side chosen:

- api.py was modified in main (#582 registered the language router) and deleted here.
  Kept deleted; the nine include_router calls in __init__.py were verified identical to
  main's, prefixes and tags included, so the deletion drops no route.
- architectural_patterns.md is a union: main's amended shared-models rule, which now names
  User, plus this branch's rename of api.py to the package __init__. The re-export bullet
  is reworded because the rename leaves two different __init__.py roles in one section.

auth.py auto-merged without a conflict and silently kept this branch's pre-fix import,
dropping main's. Restored from main. Verified by diffing the whole tree against main: no
file outside the four this branch owns differs from it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants