Skip to content

feat: new http foundation - #256

Open
kairoaraujo wants to merge 14 commits into
mainfrom
feat/new-http-foundation
Open

feat: new http foundation#256
kairoaraujo wants to merge 14 commits into
mainfrom
feat/new-http-foundation

Conversation

@kairoaraujo

Copy link
Copy Markdown
Collaborator

Summary

Foundation for the phased Werkzeug to FastAPI HTTP migration (see
docs/http-migration.md). It adds the three pieces every later group PR builds on: a
route-group registry (which group owns a path), an umbrella FastAPI app that mounts
the new routers, and a WSGI dispatcher that sends each request to the new stack or to
the legacy wsgi.py based on a per-group web-service config switch. The point of
the switch: any migrated group can be flipped back to legacy in production with a
one-word config change and a restart — no rebuild, no hotfix.

This PR migrates no routes. No groups are registered yet, so every request is served
by legacy exactly as before. The only new surface is the API reference under /api/
(/api/docs, /api/redoc, /api/openapi.json), served by the new stack; legacy has
no /api/ routes. wsgi.py is untouched.

Changes

  • docs/http-migration.md, mkdocs.yml: the migration plan — principles (AS-IS,
    reversible per group), route groups, rollout/rollback, and how legacy is removed.
  • pyproject.toml, uv.lock: runtime deps fastapi, pydantic, a2wsgi,
    python-multipart; httpx added to the dev group for testing.
  • src/djehuty/route_groups.py: route-group registry; resolves a path to new or
    legacy. Registers only api-docs (always_new).
  • src/djehuty/application.py: umbrella FastAPI app (create_app); future group
    routers mount here.
  • src/djehuty/dispatch.py: WebServiceDispatcher + build_wsgi_app; falls back to
    legacy-only if the new stack cannot be imported; never imports djehuty.web.wsgi.
  • src/djehuty/web/config/runtime.py: web_service default and
    web_service_groups overrides.
  • src/djehuty/web/ui.py: parses the web-service block (JSON and XML, flat and
    object form, api-service alias); both entry points — the internal server and the
    uWSGI application — now serve the dispatcher.
  • etc/djehuty/*.json|.xml: document the web-service block in the example configs.
  • tests/unit/: six new files covering the registry, umbrella app, dispatcher,
    config parsing, the uWSGI entry point, and an isolation test asserting no new-stack
    module imports wsgi.py.

Approval Checklist

  • I agree to follow Djehuty's code of conduct.
  • I have read and I have follow the code contribution workflow.
  • Code style and conventions were respected.
  • Documentation has been updated where needed (README, docs, or examples).
  • Review approved by at least one maintainer.
  • Merge readiness (PR is squashed into a single commit and follows the commit template).

Issue Reference (optional - PRs may not be associated with an issue)

Closes #255

Screenshots (optional)

N/A — a screenshot of /api/docs could be added to show the always-on API reference.

Notes (optional)

  • The web-service config is optional: with no block, default is new, and since
    unregistered paths always resolve to legacy, behaviour is unchanged.
  • Follow-up PRs add one route group at a time (router + registry entry + AS-IS
    contract tests), each independently toggleable between new and legacy.

Replaces #242 (base retargeted from new-http to main after the API contract suite merged; GitHub's native-stacking lock prevented editing the base in place, so this is a fresh PR with the same branch).

@kairoaraujo kairoaraujo mentioned this pull request Aug 8, 2026
6 tasks
Comment thread tests/unit/test_dispatch.py Fixed


def test_registry_entries_are_unique_route_groups():
import djehuty.route_groups as rg
@kairoaraujo
kairoaraujo marked this pull request as draft August 8, 2026 04:30
@kairoaraujo
kairoaraujo force-pushed the feat/new-http-foundation branch from 35b0253 to 4bb5e7e Compare August 8, 2026 04:32
that a missing new stack degrades to legacy instead of failing.
"""

import djehuty.route_groups as rg
@codecov

codecov Bot commented Aug 8, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 93.91304% with 7 lines in your changes missing coverage. Please review.
✅ Project coverage is 19.00%. Comparing base (11d48a0) to head (36f1b4e).

Files with missing lines Patch % Lines
src/djehuty/web/ui.py 87.50% 3 Missing and 4 partials ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #256      +/-   ##
==========================================
+ Coverage   18.10%   19.00%   +0.90%     
==========================================
  Files          21       24       +3     
  Lines       10521    10633     +112     
  Branches     2040     2063      +23     
==========================================
+ Hits         1905     2021     +116     
+ Misses       8422     8414       -8     
- Partials      194      198       +4     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@kairoaraujo
kairoaraujo marked this pull request as ready for review August 8, 2026 08:56
@kairoaraujo
kairoaraujo force-pushed the feat/new-http-foundation branch 2 times, most recently from e541fdb to 31d8802 Compare August 9, 2026 11:05
@kairoaraujo
kairoaraujo requested a review from 641e16 August 10, 2026 10:02

@641e16 641e16 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Very exciting changes and a strong foundation! Reading the http-migration documentation beforehand helped understand the code easier.

Two things to fix before merge:

  • route_groups.py: the prefixes=("/api/",) claims the whole namespace.
  • dispatch.py: create_app(db) sits outside the try/except, so a failure building the umbrella app takes down the legacy stack the fallback exists to protect. I've suggested a regression test alongside it.

also don't forget to rebase before next round.

Comment thread src/djehuty/route_groups.py Outdated
Comment thread src/djehuty/route_groups.py Outdated
Comment thread src/djehuty/dispatch.py
Comment thread src/djehuty/web/ui.py
Comment on lines +1511 to +1516
global _UWSGI_APP
if _UWSGI_APP is None:
server = main (config_file=config_file, run_internal_server=False)
from djehuty.dispatch import build_wsgi_app
_UWSGI_APP = build_wsgi_app (server, server.db,
config.web_service, config.web_service_groups)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Nothing checks whether main() succeeded before using its result.

Suggested change
global _UWSGI_APP
if _UWSGI_APP is None:
server = main (config_file=config_file, run_internal_server=False)
from djehuty.dispatch import build_wsgi_app
_UWSGI_APP = build_wsgi_app (server, server.db,
config.web_service, config.web_service_groups)
global _UWSGI_APP
if _UWSGI_APP is None:
server = main (config_file=config_file, run_internal_server=False)
if server is None:
start_response('500 Internal Server Error', [('Content-Type','text/html')])
return [b"<p>djehuty failed to start. See the log for details.</p>"]
from djehuty.dispatch import build_wsgi_app
_UWSGI_APP = build_wsgi_app (server, server.db,
config.web_service, config.web_service_groups)

Same failure mode pattern already done above.

Comment thread src/djehuty/web/ui.py
Comment on lines 1489 to +1517
@@ -1424,5 +1508,10 @@ def application (env, start_response):
start_response('200 OK', [('Content-Type','text/html')])
return [b"<p>Please set the <code>DJEHUTY_CONFIG_FILE</code> environment variable.</p>"]

server = main (config_file=config_file, run_internal_server=False)
return server (env, start_response)
global _UWSGI_APP
if _UWSGI_APP is None:
server = main (config_file=config_file, run_internal_server=False)
from djehuty.dispatch import build_wsgi_app
_UWSGI_APP = build_wsgi_app (server, server.db,
config.web_service, config.web_service_groups)
return _UWSGI_APP (env, start_response)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

make the check and set atomic by using a lock.

something like :

import threading

_UWSGI_APP = None
_UWSGI_LOCK = threading.Lock()

...

    global _UWSGI_APP
    if _UWSGI_APP is None:              # fast path, no lock once booted
        with _UWSGI_LOCK:
            if _UWSGI_APP is None:      # the check that actually matters
                server = main (...)
                _UWSGI_APP = build_wsgi_app (...)
    return _UWSGI_APP (env, start_response)

with _UWSGI_LOCK only one thread at a time gets past that line; the others wait. the next thread doesn't start the process while first thread is running main which takes a bit of time.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

IMHO, Not needed here. This path only runs under uWSGI, and each worker is a separate process that boots _UWSGI_APP once, there's no shared state and no concurrent first-request race to lock against. I'll add the double-checked lock if we ever move to threaded workers.

Comment thread src/djehuty/web/ui.py
Comment on lines +246 to +247
config.web_service_groups.update (_read_web_service_targets (groups_node, logger))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

more of a documentation gap than a bug: Config files can other config files, and read_configuration_file reads its own settings first (line 860) and only processes includes afterwards (line 1045). So if an included file also has a web-service block, it overwrites the top-level one. Last file read wins, and nothing is logged about it.

Every setting in this file already works that way, so web-service is consistent with the rest. But it's the first setting operators are told to change during an incident. Someone follows the rollback steps in docs/http-migration.md, edits the main config, restarts, and if an included file happens to set web-service too, nothing changes.

Separately, config.web_service_groups.update(...) merges not replaces, so an included file can add or change a group override but can not clear one the parent set.

suggestions:

  • Point operators at the log. build_wsgi_app already logs default=... overrides=... at INFO on startup, so the effective values are recoverable. Adding "check this log line after restarting" to the rollback runbook might be enough.
  • Add a note to docs/http-migration.md saying included files take precedence, so others know to check them before editing.
  • Decide whether .update() is the behaviour you want for groups, or document that an include can't clear an override.

Comment on lines +21 to +22
NEW_STACK = ("djehuty.route_groups", "djehuty.application", "djehuty.dispatch")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

so requires manual maintanance currently as we have to not forget to add here for each group PR. it would be best if we discover NEW_STACK by walking the package rather than listing it to make it self-maintaining, but not a big deal.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Agreed it'd be cleaner, but leaving it explicit for now. Forgetting to add a module fails loudly and gets caught in review, and it's one line per group PR. Happy to revisit if the list grows.

Comment thread tests/unit/test_new_stack_isolation.py Outdated
Comment on lines +35 to +36
elif isinstance(node, ast.ImportFrom) and node.level == 0 and node.module:
modules.add(node.module)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

needs fixing:

This guardrail catches only 2 of the 6 ways i tested one can write the forbidden import.

node.level == 0 skips every relative import, and for from x import y it records only x, throwing y away. I ran the current logic against each form:

OKAY import djehuty.web.wsgi
OKAY from djehuty.web.wsgi import WebServer
MISSED from djehuty.web import wsgi
MISSED from .web import wsgi
MISSED from .web.wsgi import WebServer
The third line is most important as that import style pops up in our documentation.

Matters, because every later group PR will be reviewed on the assumption this test works!

suggestion:

  • create new function such as

def _imported_modules(tree: ast.AST, package: str) -> set: """Every module the file imports: absolute, relative, and from-imported names.""" pass

Comment thread tests/unit/test_dispatch.py
@kairoaraujo
kairoaraujo force-pushed the feat/new-http-foundation branch from c058a02 to 451630f Compare August 10, 2026 17:39
@kairoaraujo

Copy link
Copy Markdown
Collaborator Author

thanks for the review @641e16

@kairoaraujo
kairoaraujo requested a review from 641e16 August 10, 2026 17:46

@641e16 641e16 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Thank you for the changes! Looks good to me :)

The only minor things I see that may be worth checking out:

  • uv.lock maybe needs regenerating with the pinned uv?
  • for packaging: spec.in, guix.scm.in, requirements.txt, pyproject.toml.in still have no fastapi? but we are moving away from them, right?
  • djehuty-example-config.xml:4 -> doc/http-migration.md should be docs/; doc/ is the LaTeX manual.

Also, You could add the three new modules to the include list under [tool.ruff] and run just format. The code is already well formatted so it would be nice to add it already as part of the formatted files.

Comment thread etc/djehuty/djehuty-example-config.xml Outdated
<maintenance-mode>0</maintenance-mode>
<!-- New HTTP stack switch (see doc/http-migration.md). default is new|legacy;
list a functional group under <groups> to pin it, e.g.
<groups><api-v2>legacy</api-v2></groups>. The docs (/api/) are always new. -->

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

one quick fix: this needs to be updated to mention the three URLs not the directory prefix.

@kairoaraujo

Copy link
Copy Markdown
Collaborator Author
  • uv.lock maybe needs regenerating with the pinned uv?

I think we need to fix it in the release process, because it happens after every release. I will investigate it, maybe we need a issue for it.

  • for packaging: spec.in, guix.scm.in, requirements.txt, pyproject.toml.in still have no fastapi? but we are moving away from them, right?

Right, leaving those as-is since we're moving away from them; pyproject.toml is the source of truth for the runtime deps.

@641e16 641e16 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The changes look good! I approve.

kairoaraujo and others added 12 commits August 31, 2026 12:01
Add docs/http-migration.md: how we ship the FastAPI stack on refact/new-http
group by group, with a per-group new/legacy switch so any group can be flipped
back to legacy in production via config + restart.

Signed-off-by: Kairo de Araujo <kairo@dearaujo.nl>
Add fastapi, pydantic, a2wsgi and python-multipart as runtime dependencies, and
httpx as a dev dependency for Starlette's TestClient. No code uses these yet.

Signed-off-by: Kairo de Araujo <kairo@dearaujo.nl>
Signed-off-by: Kairo de Araujo <kairo@dearaujo.nl>
- config: web_service (default "new"|"legacy") + web_service_groups overrides.
- ui.py: read_web_service_configuration parses the flat value and the object
  form (default + groups), JSON and XML, with "api-service" as an alias.
  run_simple now serves the dispatcher.
- dispatch.py: WebServiceDispatcher routes per route_groups.target_for_path;
  build_wsgi_app wraps the umbrella app + legacy and falls back to legacy if the
  new stack cannot be imported. Never imports djehuty.web.wsgi.
- example configs: document the web-service block (default new, empty groups;
  each group PR adds its own line).

Signed-off-by: Kairo de Araujo <kairo@dearaujo.nl>
Route-group matching and resolution (incl. the api-docs group and always_new
staying new when everything is disabled), config parsing (flat and object, JSON
and XML, api-service alias), dispatcher routing and legacy fallback, the umbrella
serving /api/docs, and a guardrail that no new-stack module imports the legacy
WSGI app.

Signed-off-by: Kairo de Araujo <kairo@dearaujo.nl>
Strip whitespace, lowercase, and warn on unrecognized values so a
typo or indented config no longer silently stays on the wrong target.

Signed-off-by: Kairo de Araujo <kairo@dearaujo.nl>
Co-authored-by: vic <110847019+641e16@users.noreply.github.com>
Signed-off-by: Kairo de Araujo <kairo@dearaujo.nl>
Signed-off-by: Kairo de Araujo <kairo@dearaujo.nl>
Signed-off-by: Kairo de Araujo <kairo@dearaujo.nl>
Signed-off-by: Kairo de Araujo <kairo@dearaujo.nl>
Signed-off-by: Kairo de Araujo <kairo@dearaujo.nl>
@kairoaraujo
kairoaraujo force-pushed the feat/new-http-foundation branch from 613eed8 to 36f1b4e Compare August 31, 2026 10:13
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.

[task]: Implement the foundation for the new http with legacy/new switch

2 participants