mirror of
https://github.com/zvx-echo6/navi.git
synced 2026-08-26 17:31:37 +00:00
Initial scaffold: navi-backend + navi-traffic (extraction #1)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
dfd1d38405
15 changed files with 491 additions and 0 deletions
17
backend/.gitignore
vendored
Normal file
17
backend/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.egg-info/
|
||||
build/
|
||||
dist/
|
||||
|
||||
# Virtualenv
|
||||
.venv/
|
||||
|
||||
# Test / tooling caches
|
||||
.pytest_cache/
|
||||
.ruff_cache/
|
||||
|
||||
# Local env files (secrets live in /etc/navi-backend/*.env on the host)
|
||||
*.env
|
||||
.env
|
||||
21
backend/LICENSE
Normal file
21
backend/LICENSE
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2026 Matt Johnson / Echo6
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
61
backend/README.md
Normal file
61
backend/README.md
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
# navi-backend
|
||||
|
||||
Monorepo of small, single-responsibility HTTP services extracted from the `recon`
|
||||
codebase as part of the **recon ↔ Navi decoupling** project. Each service owns a
|
||||
slice of the `/api/*` surface that `navi.echo6.co` depends on, runs behind the
|
||||
existing Caddy/Authentik edge, and is fronted by the `navi.echo6.co` nginx vhost.
|
||||
|
||||
See `HANDOFF-recon-navi-decoupling-v3.md` for the full plan. This repo is
|
||||
extraction **#1**: `navi-traffic`.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
navi-backend/
|
||||
├── shared/ # cross-service helpers, imported by every service
|
||||
│ ├── auth.py # get_user_id(req), require_auth decorator (Authentik header)
|
||||
│ └── admin_info.py # build_info_response(), mask_key(), time_dependency()
|
||||
├── services/
|
||||
│ └── navi_traffic/ # extraction #1 — TomTom traffic tile proxy (:8421)
|
||||
│ ├── app.py # Flask factory (create_app) + gunicorn entry
|
||||
│ ├── traffic.py # /api/traffic/flow/<z>/<x>/<y>.png (ported from recon)
|
||||
│ ├── admin.py # /api/admin/navi-traffic/info (§4.5 admin convention)
|
||||
│ └── tests/
|
||||
└── deploy/
|
||||
├── systemd/navi-traffic.service
|
||||
└── nginx/navi-traffic.conf.snippet
|
||||
```
|
||||
|
||||
Service directories use an underscore (`navi_traffic`) so they're importable
|
||||
Python packages; the **service name** stays `navi-traffic` (hyphen) in systemd,
|
||||
nginx, and the admin-info `service` field.
|
||||
|
||||
## Setup
|
||||
|
||||
Single workspace, single virtualenv:
|
||||
|
||||
```bash
|
||||
python -m venv .venv
|
||||
.venv/bin/pip install -e .
|
||||
```
|
||||
|
||||
## Test
|
||||
|
||||
```bash
|
||||
.venv/bin/pytest services/navi_traffic/tests/ -v
|
||||
```
|
||||
|
||||
## Run (local)
|
||||
|
||||
```bash
|
||||
TOMTOM_API_KEY=... .venv/bin/gunicorn 'services.navi_traffic.app:create_app()' \
|
||||
--bind 127.0.0.1:8421 --workers 2
|
||||
```
|
||||
|
||||
## The admin-info convention (§4.5)
|
||||
|
||||
Every service exposes `GET /api/admin/<service-name>/info`, gated by `require_auth`,
|
||||
returning a uniform shape: `service`, `version` (git SHA), `port`, `config`, `env`
|
||||
(names + masked values), `dependencies` (upstream health checks), `filesystem`,
|
||||
`runtime` (uptime / request count / last error). No aggregator — a future admin
|
||||
panel fans out to each service in parallel.
|
||||
44
backend/deploy/nginx/navi-traffic.conf.snippet
Normal file
44
backend/deploy/nginx/navi-traffic.conf.snippet
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
# =============================================================================
|
||||
# navi-traffic — nginx integration for the navi.echo6.co vhost
|
||||
#
|
||||
# Two parts, both required. Behavior-neutral with respect to the existing
|
||||
# `location /api/` proxy: only /api/traffic/* requests change path (they now go
|
||||
# to the navi-traffic service on :8421 and get an nginx cache layer).
|
||||
# =============================================================================
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# (A) NEW FILE: /etc/nginx/conf.d/traffic-cache.conf
|
||||
# http{} context (conf.d/* is included there). Defines the proxy_cache zone
|
||||
# consumed by section (B). Mirrors the existing satellite-cache.conf and
|
||||
# dem-cache.conf pattern verified in Phase 0.
|
||||
# -----------------------------------------------------------------------------
|
||||
proxy_cache_path /mnt/nav/tile-cache/traffic levels=1:2
|
||||
keys_zone=traffic_cache:10m
|
||||
max_size=10g
|
||||
inactive=24h
|
||||
use_temp_path=off;
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# (B) EDIT: /etc/nginx/sites-available/navi.echo6.co
|
||||
# Add the location block below INSIDE the existing
|
||||
# server { server_name navi.echo6.co; ... }
|
||||
# block, placed BEFORE the existing `location /api/ { ... }` block.
|
||||
#
|
||||
# nginx selects the longest matching prefix, so `/api/traffic/` wins over
|
||||
# `/api/` regardless of order — placing it first keeps the file readable and
|
||||
# makes the precedence obvious to a human.
|
||||
# -----------------------------------------------------------------------------
|
||||
location /api/traffic/ {
|
||||
proxy_pass http://127.0.0.1:8421;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_read_timeout 10s;
|
||||
|
||||
# Cache successful tiles for 120s (matches the handler's Cache-Control).
|
||||
proxy_cache traffic_cache;
|
||||
proxy_cache_valid 200 120s;
|
||||
proxy_cache_use_stale error timeout updating;
|
||||
add_header X-Cache-Status $upstream_cache_status;
|
||||
}
|
||||
15
backend/deploy/systemd/navi-traffic.service
Normal file
15
backend/deploy/systemd/navi-traffic.service
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
[Unit]
|
||||
Description=navi-traffic — TomTom traffic tile proxy (Echo6 navi-backend, extraction #1)
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
User=zvx
|
||||
WorkingDirectory=/home/zvx/projects/repos/navi-backend
|
||||
EnvironmentFile=/etc/navi-backend/navi-traffic.env
|
||||
ExecStart=/home/zvx/projects/repos/navi-backend/.venv/bin/gunicorn 'services.navi_traffic.app:create_app()' --bind 127.0.0.1:8421 --workers 2
|
||||
Restart=on-failure
|
||||
RestartSec=2
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
25
backend/pyproject.toml
Normal file
25
backend/pyproject.toml
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
[build-system]
|
||||
requires = ["setuptools>=68"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "navi-backend"
|
||||
version = "0.1.0"
|
||||
description = "Echo6 navi-backend monorepo — Navi API services extracted from recon (decoupling project)."
|
||||
requires-python = ">=3.10"
|
||||
# Single workspace, single .venv. pytest is included so `pip install -e .` is the
|
||||
# only setup step needed before running the test suite.
|
||||
dependencies = [
|
||||
"Flask>=3.0",
|
||||
"gunicorn>=21",
|
||||
"requests>=2.31",
|
||||
"pytest>=8",
|
||||
]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["."]
|
||||
include = ["shared*", "services*"]
|
||||
namespaces = true
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["services"]
|
||||
0
backend/services/navi_traffic/__init__.py
Normal file
0
backend/services/navi_traffic/__init__.py
Normal file
42
backend/services/navi_traffic/admin.py
Normal file
42
backend/services/navi_traffic/admin.py
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
"""navi-traffic admin-info endpoint (handoff §4.5).
|
||||
|
||||
``GET /api/admin/navi-traffic/info`` — Authentik-gated, read-only. Exposes the
|
||||
service's version, port, masked env, upstream health, and runtime counters.
|
||||
"""
|
||||
import os
|
||||
import time
|
||||
|
||||
from flask import Blueprint, jsonify, current_app
|
||||
|
||||
from shared.auth import require_auth
|
||||
from shared.admin_info import build_info_response, mask_key, time_dependency
|
||||
|
||||
bp = Blueprint('admin', __name__)
|
||||
|
||||
PORT = 8421
|
||||
|
||||
|
||||
@bp.route('/api/admin/navi-traffic/info')
|
||||
@require_auth
|
||||
def navi_traffic_info():
|
||||
metrics = current_app.config['METRICS']
|
||||
info = build_info_response(
|
||||
service='navi-traffic',
|
||||
version=current_app.config.get('VERSION', 'unknown'),
|
||||
port=PORT,
|
||||
config={},
|
||||
env=[{
|
||||
'name': 'TOMTOM_API_KEY',
|
||||
'value': mask_key(os.environ.get('TOMTOM_API_KEY')),
|
||||
}],
|
||||
dependencies=[
|
||||
time_dependency('tomtom-api', 'https://api.tomtom.com/'),
|
||||
],
|
||||
filesystem=[],
|
||||
runtime={
|
||||
'uptime_s': round(time.time() - metrics['start_time'], 1),
|
||||
'request_count': metrics['request_count'],
|
||||
'last_error_at': metrics['last_error_at'],
|
||||
},
|
||||
)
|
||||
return jsonify(info)
|
||||
54
backend/services/navi_traffic/app.py
Normal file
54
backend/services/navi_traffic/app.py
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
"""navi-traffic Flask application factory + gunicorn entry.
|
||||
|
||||
Gunicorn entry:
|
||||
gunicorn 'services.navi_traffic.app:create_app()' --bind 127.0.0.1:8421 --workers 2
|
||||
"""
|
||||
import subprocess
|
||||
import time
|
||||
|
||||
from flask import Flask
|
||||
|
||||
from . import traffic, admin
|
||||
|
||||
|
||||
def _git_sha():
|
||||
"""Short git SHA of the working tree at startup, or 'unknown' off-repo."""
|
||||
try:
|
||||
sha = subprocess.check_output(
|
||||
['git', 'rev-parse', '--short', 'HEAD'],
|
||||
stderr=subprocess.DEVNULL,
|
||||
text=True,
|
||||
).strip()
|
||||
return sha or 'unknown'
|
||||
except Exception:
|
||||
return 'unknown'
|
||||
|
||||
|
||||
def create_app():
|
||||
app = Flask(__name__)
|
||||
|
||||
# Version + lightweight runtime metrics, read by the admin-info endpoint.
|
||||
# NOTE: with gunicorn --workers 2 these counters are per-worker (each worker
|
||||
# is its own process); they are indicative, not cluster-wide totals.
|
||||
app.config['VERSION'] = _git_sha()
|
||||
app.config['METRICS'] = {
|
||||
'start_time': time.time(),
|
||||
'request_count': 0,
|
||||
'last_error_at': None,
|
||||
}
|
||||
|
||||
@app.before_request
|
||||
def _count_request():
|
||||
app.config['METRICS']['request_count'] += 1
|
||||
|
||||
@app.after_request
|
||||
def _track_errors(response):
|
||||
if response.status_code >= 500:
|
||||
app.config['METRICS']['last_error_at'] = time.strftime(
|
||||
'%Y-%m-%dT%H:%M:%SZ', time.gmtime()
|
||||
)
|
||||
return response
|
||||
|
||||
app.register_blueprint(traffic.bp)
|
||||
app.register_blueprint(admin.bp)
|
||||
return app
|
||||
0
backend/services/navi_traffic/tests/__init__.py
Normal file
0
backend/services/navi_traffic/tests/__init__.py
Normal file
75
backend/services/navi_traffic/tests/test_traffic.py
Normal file
75
backend/services/navi_traffic/tests/test_traffic.py
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
"""Tests for the navi-traffic flow proxy.
|
||||
|
||||
Covers all four status paths (200 / 503 / 502 / 504) and the success-path
|
||||
headers, plus the mask_key convention. The TomTom upstream is mocked — no
|
||||
network calls.
|
||||
"""
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from services.navi_traffic.app import create_app
|
||||
from shared.admin_info import mask_key
|
||||
|
||||
UPSTREAM = (
|
||||
'https://api.tomtom.com/maps/orbis/traffic/tile/flow/'
|
||||
'10/200/400.png?key=abcdef123456&apiVersion=1&style=light'
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(monkeypatch):
|
||||
monkeypatch.setenv('TOMTOM_API_KEY', 'abcdef123456')
|
||||
app = create_app()
|
||||
app.testing = True
|
||||
return app.test_client()
|
||||
|
||||
|
||||
def test_flow_success_returns_png_with_cache_headers(client):
|
||||
fake = MagicMock(status_code=200, content=b'\x89PNG\r\n fake tile bytes')
|
||||
with patch('services.navi_traffic.traffic.http_requests.get',
|
||||
return_value=fake) as mock_get:
|
||||
resp = client.get('/api/traffic/flow/10/200/400.png')
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.headers['Content-Type'] == 'image/png'
|
||||
assert resp.headers['Cache-Control'] == 'public, max-age=120'
|
||||
assert resp.data == b'\x89PNG\r\n fake tile bytes'
|
||||
# exact upstream URL + params + 10s timeout (behavior-neutral port)
|
||||
assert mock_get.call_args.args[0] == UPSTREAM
|
||||
assert mock_get.call_args.kwargs.get('timeout') == 10
|
||||
|
||||
|
||||
def test_flow_missing_key_returns_503(monkeypatch):
|
||||
monkeypatch.delenv('TOMTOM_API_KEY', raising=False)
|
||||
app = create_app()
|
||||
app.testing = True
|
||||
resp = app.test_client().get('/api/traffic/flow/1/2/3.png')
|
||||
assert resp.status_code == 503
|
||||
assert b'not configured' in resp.data
|
||||
|
||||
|
||||
def test_flow_upstream_non_200_returns_502(client):
|
||||
fake = MagicMock(status_code=403, content=b'')
|
||||
with patch('services.navi_traffic.traffic.http_requests.get',
|
||||
return_value=fake):
|
||||
resp = client.get('/api/traffic/flow/1/2/3.png')
|
||||
assert resp.status_code == 502
|
||||
assert b'Upstream error' in resp.data
|
||||
|
||||
|
||||
def test_flow_exception_returns_504(client):
|
||||
with patch('services.navi_traffic.traffic.http_requests.get',
|
||||
side_effect=Exception('connection reset')):
|
||||
resp = client.get('/api/traffic/flow/1/2/3.png')
|
||||
assert resp.status_code == 504
|
||||
assert b'Upstream timeout' in resp.data
|
||||
|
||||
|
||||
def test_mask_key_recon_pattern():
|
||||
# matches recon's api_keys_admin._mask_key: first4 + '...' + last4
|
||||
assert mask_key('') is None
|
||||
assert mask_key(None) is None
|
||||
assert mask_key('abcdefgh') == '****' # 8 chars, fully masked
|
||||
assert mask_key('tk_1234567890ABCDEF') == 'tk_1...CDEF'
|
||||
assert mask_key('TOMTOM_KEY_abcdefXYZ') == 'TOMT...fXYZ'
|
||||
32
backend/services/navi_traffic/traffic.py
Normal file
32
backend/services/navi_traffic/traffic.py
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
"""TomTom traffic flow tile proxy.
|
||||
|
||||
Behavior-neutral port of recon ``lib/api.py:1212-1229`` (the PR #4 Orbis flow
|
||||
proxy). Same upstream URL, same buffered passthrough, same headers, same error
|
||||
codes. The only purpose is to keep ``TOMTOM_API_KEY`` server-side.
|
||||
"""
|
||||
import os
|
||||
|
||||
import requests as http_requests
|
||||
from flask import Blueprint, make_response
|
||||
|
||||
bp = Blueprint('traffic', __name__)
|
||||
|
||||
|
||||
@bp.route('/api/traffic/flow/<int:z>/<int:x>/<int:y>.png')
|
||||
def api_traffic_flow(z, x, y):
|
||||
"""Proxy TomTom traffic flow tiles to hide API key from frontend."""
|
||||
key = os.environ.get('TOMTOM_API_KEY')
|
||||
if not key:
|
||||
return 'Traffic service not configured', 503
|
||||
# Orbis Maps Traffic API (migrated from classic)
|
||||
url = f'https://api.tomtom.com/maps/orbis/traffic/tile/flow/{z}/{x}/{y}.png?key={key}&apiVersion=1&style=light'
|
||||
try:
|
||||
resp = http_requests.get(url, timeout=10)
|
||||
if resp.status_code != 200:
|
||||
return 'Upstream error', 502
|
||||
r = make_response(resp.content)
|
||||
r.headers['Content-Type'] = 'image/png'
|
||||
r.headers['Cache-Control'] = 'public, max-age=120'
|
||||
return r
|
||||
except Exception:
|
||||
return 'Upstream timeout', 504
|
||||
0
backend/shared/__init__.py
Normal file
0
backend/shared/__init__.py
Normal file
76
backend/shared/admin_info.py
Normal file
76
backend/shared/admin_info.py
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
"""Helpers for the uniform ``/api/admin/<service>/info`` endpoint (handoff §4.5).
|
||||
|
||||
Every navi-backend service exposes one admin-info endpoint with the same shape so
|
||||
a future admin panel can fan out to all of them. These helpers keep each
|
||||
service's handler down to a few lines.
|
||||
"""
|
||||
import time
|
||||
|
||||
import requests
|
||||
|
||||
|
||||
def mask_key(value):
|
||||
"""Mask a secret for display, matching recon's ``api_keys_admin._mask_key``.
|
||||
|
||||
Pattern: ``first4 + '...' + last4`` (e.g. ``"tk_1...CDEF"``). Values of 8
|
||||
chars or fewer are fully masked as ``'****'`` so short strings don't reveal
|
||||
their endpoints. Returns ``None`` for empty/None input.
|
||||
"""
|
||||
if not value:
|
||||
return None
|
||||
if len(value) <= 8:
|
||||
return '****'
|
||||
return value[:4] + '...' + value[-4:]
|
||||
|
||||
|
||||
def time_dependency(name, url, method='HEAD', timeout=5):
|
||||
"""Health-check an upstream dependency.
|
||||
|
||||
Returns ``{name, status, latency_ms}`` (plus ``error`` on failure), where
|
||||
``status`` is ``"ok"`` for any response below HTTP 500, else ``"error"``.
|
||||
"""
|
||||
start = time.monotonic()
|
||||
try:
|
||||
resp = requests.request(method, url, timeout=timeout)
|
||||
latency_ms = round((time.monotonic() - start) * 1000, 1)
|
||||
if resp.status_code < 500:
|
||||
return {'name': name, 'status': 'ok', 'latency_ms': latency_ms}
|
||||
return {
|
||||
'name': name,
|
||||
'status': 'error',
|
||||
'latency_ms': latency_ms,
|
||||
'error': f'HTTP {resp.status_code}',
|
||||
}
|
||||
except Exception as exc:
|
||||
latency_ms = round((time.monotonic() - start) * 1000, 1)
|
||||
return {
|
||||
'name': name,
|
||||
'status': 'error',
|
||||
'latency_ms': latency_ms,
|
||||
'error': str(exc),
|
||||
}
|
||||
|
||||
|
||||
def build_info_response(service, version, port, config, env, dependencies,
|
||||
filesystem, runtime):
|
||||
"""Assemble the uniform admin-info dict (handoff §4.5).
|
||||
|
||||
- ``service`` short name, e.g. ``"navi-traffic"``
|
||||
- ``version`` git SHA (or semver if tagged)
|
||||
- ``port`` listening port
|
||||
- ``config`` loaded config dict, secrets masked
|
||||
- ``env`` list of ``{name, value}`` with values masked via mask_key
|
||||
- ``dependencies`` list of time_dependency() results
|
||||
- ``filesystem`` list of /mnt/nav paths with existence + read checks
|
||||
- ``runtime`` ``{uptime_s, request_count, last_error_at}``
|
||||
"""
|
||||
return {
|
||||
'service': service,
|
||||
'version': version,
|
||||
'port': port,
|
||||
'config': config,
|
||||
'env': env,
|
||||
'dependencies': dependencies,
|
||||
'filesystem': filesystem,
|
||||
'runtime': runtime,
|
||||
}
|
||||
29
backend/shared/auth.py
Normal file
29
backend/shared/auth.py
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
"""Authentication helpers shared across every navi-backend service.
|
||||
|
||||
Auth is enforced at the Caddy/Authentik edge (forward_auth). By the time a
|
||||
request reaches a service it carries an ``X-Authentik-Username`` header iff the
|
||||
user is authenticated. These helpers read that header — they do not perform
|
||||
auth themselves, they assert that the edge already did.
|
||||
"""
|
||||
from functools import wraps
|
||||
|
||||
from flask import request, jsonify
|
||||
|
||||
|
||||
def get_user_id(req):
|
||||
"""Return the Authentik-supplied username for a request, or None if absent."""
|
||||
return req.headers.get('X-Authentik-Username')
|
||||
|
||||
|
||||
def require_auth(fn):
|
||||
"""Reject requests with no ``X-Authentik-Username`` header (401 JSON).
|
||||
|
||||
Use on endpoints that must never be reachable unauthenticated even if the
|
||||
edge is misconfigured or bypassed (e.g. the internal nginx :8440 path).
|
||||
"""
|
||||
@wraps(fn)
|
||||
def wrapper(*args, **kwargs):
|
||||
if not get_user_id(request):
|
||||
return jsonify({'error': 'authentication required'}), 401
|
||||
return fn(*args, **kwargs)
|
||||
return wrapper
|
||||
Loading…
Add table
Add a link
Reference in a new issue