update
DevPlace CI / test (push) Has been cancelled

This commit is contained in:
2026-07-23 01:15:04 +02:00
parent 582e37d176
commit b534a496fd
79 changed files with 4086 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
# Web Service (stub)
View File
+616
View File
@@ -0,0 +1,616 @@
import os
import devplacepy.db_client
import asyncio
import logging
import time
from collections import defaultdict
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI, Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from starlette.middleware.gzip import GZipMiddleware
from devplacepy.cache import TTLCache
from devplacepy.config import (
PORT,
STATIC_DIR,
STATIC_VERSION,
UPLOADS_DIR,
ensure_data_dirs,
)
from devplacepy.db_client import (
db,
get_blocked_uids,
get_comment_counts_by_post_uids,
get_int_setting,
get_news_images_by_uids,
get_setting,
get_table,
get_user_post_count,
get_user_stars,
get_users_by_uids,
get_vote_counts,
interleave_by_author,
)
from devplacepy.responses import json_error, respond, wants_json
from devplacepy.routers import (
admin,
auth,
avatar,
awards,
bookmarks,
comments,
dbapi,
devrant,
docs,
feed,
follow,
game,
gists,
issues,
leaderboard,
media,
messages,
news,
notifications,
polls,
posts,
profile,
projects,
proxy,
push,
reactions,
relations,
seo,
uploads,
votes,
)
from devplacepy.schemas import LandingOut, ValidationErrorOut
from devplacepy.seo import base_seo_context, site_url, website_schema
from devplacepy.services import presence
from devplacepy.services.audit import record as audit
from devplacepy.services.correction import PENDING_SCOPE_KEY
from devplacepy.templating import templates
from devplacepy.utils import client_ip, get_current_user, safe_next, time_ago
from devplacepy_services.web.ingress import mount_ingress
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
)
logger = logging.getLogger(__name__)
_rate_limit_store = defaultdict(list)
RATE_LIMIT = int(os.environ.get("DEVPLACE_RATE_LIMIT", "60"))
RATE_WINDOW = 60
WEB_WORKERS = max(1, int(os.environ.get("DEVPLACE_WEB_WORKERS", "1")))
RATE_LIMIT_DISABLED = os.environ.get("DEVPLACE_DISABLE_RATE_LIMIT") == "1"
HOT_SETTINGS_TTL = 2.0
_hot_settings_value: dict = {}
_hot_settings_at = 0.0
_last_rate_sweep = 0.0
RATE_SWEEP_INTERVAL = 60.0
def _hot_settings() -> dict:
global _hot_settings_value, _hot_settings_at
now = time.monotonic()
if not _hot_settings_value or now - _hot_settings_at >= HOT_SETTINGS_TTL:
_hot_settings_value = {
"maintenance_mode": get_setting("maintenance_mode", "0"),
"rate_limit_per_minute": max(
1, get_int_setting("rate_limit_per_minute", RATE_LIMIT)
),
"rate_limit_window_seconds": max(
1, get_int_setting("rate_limit_window_seconds", RATE_WINDOW)
),
}
_hot_settings_at = now
return _hot_settings_value
def _sweep_rate_limit_store(window_start: float) -> None:
stale = [
ip
for ip, timestamps in _rate_limit_store.items()
if not timestamps or timestamps[-1] <= window_start
]
for ip in stale:
del _rate_limit_store[ip]
def _worker_rate_limit(limit: int) -> int:
return max(1, -(-limit // WEB_WORKERS))
INLINE_MEDIA_EXTENSIONS = {
".jpg",
".jpeg",
".png",
".gif",
".webp",
".bmp",
".tiff",
".mp4",
".webm",
".ogv",
".mov",
".m4v",
".mp3",
}
class UploadStaticFiles(StaticFiles):
async def get_response(self, path, scope):
response = await super().get_response(path, scope)
disposition = (
"inline"
if Path(path).suffix.lower() in INLINE_MEDIA_EXTENSIONS
else "attachment"
)
response.headers["Content-Disposition"] = disposition
response.headers["Cache-Control"] = "public, max-age=604800"
return response
class CachedStaticFiles(StaticFiles):
async def get_response(self, path, scope):
response = await super().get_response(path, scope)
if Path(path).name == "service-worker.js":
response.headers["Cache-Control"] = "no-cache"
else:
response.headers["Cache-Control"] = "public, max-age=31536000, immutable"
return response
class FallbackStaticFiles(StaticFiles):
async def get_response(self, path, scope):
response = await super().get_response(path, scope)
if Path(path).name == "service-worker.js":
response.headers["Cache-Control"] = "no-cache"
else:
response.headers["Cache-Control"] = "public, max-age=3600"
return response
@asynccontextmanager
async def lifespan(app: FastAPI):
ensure_data_dirs()
from devplacepy.services.statistics.tracking import start_visit_flusher
start_visit_flusher()
from devplacepy.config import SERVICE_LOCK_FILE
from devplacepy.services.dbapi.service import DbApiJobService
from devplacepy.services.manager import service_manager
from devplacepy.services.weblock import acquire_web_lock
web_lock_owner = acquire_web_lock(SERVICE_LOCK_FILE)
if web_lock_owner:
service_manager.register(DbApiJobService())
service_manager.set_lock_owner(True)
service_manager.supervise()
logger.info(f"DevPlace web started on port {PORT}")
yield
logger.info("Shutting down web...")
if web_lock_owner:
await service_manager.shutdown_all()
from devplacepy.services.statistics.tracking import flush_visits
flush_visits()
_AUTH_FORM_PAGES = {
"/auth/signup": ("signup.html", "Join DevPlace"),
"/auth/login": ("login.html", "Sign In"),
"/auth/forgot-password": ("forgot_password.html", "Reset Password"),
}
_FRIENDLY_ERRORS = {
("username", "too_short"): "Username must be between 3 and 32 characters",
("username", "too_long"): "Username must be between 3 and 32 characters",
("password", "too_short"): "Password must be at least 6 characters",
}
def _friendly_error(err):
field = err["loc"][-1] if err.get("loc") else ""
key = (field, err.get("type", "").replace("string_", ""))
if key in _FRIENDLY_ERRORS:
return _FRIENDLY_ERRORS[key]
msg = err.get("msg", "Invalid input")
prefix = "Value error, "
return msg[len(prefix) :] if msg.startswith(prefix) else msg
_home_cache = TTLCache(ttl=int(os.environ.get("DEVPLACE_HOME_CACHE_TTL", "60")), max_size=4)
def _landing_news():
cached = _home_cache.get("news")
if cached is not None:
return cached
articles = []
if "news" in db.tables:
news_table = get_table("news")
raw = list(
news_table.find(show_on_landing=1, order_by=["-synced_at"], _limit=6)
)
images_by_news = get_news_images_by_uids([a["uid"] for a in raw])
for a in raw:
articles.append(
{
"uid": a["uid"],
"slug": a.get("slug", ""),
"title": a.get("title", ""),
"description": (a.get("description", "") or "")[:250],
"url": a.get("url", ""),
"source_name": a.get("source_name", ""),
"grade": a.get("grade", 0),
"featured": a.get("featured", 0),
"synced_at": a.get("synced_at", "") or "",
"time_ago": time_ago(a["synced_at"]) if a.get("synced_at") else "",
"image_url": a.get("image_url", "") or images_by_news.get(a["uid"], ""),
}
)
_home_cache.set("news", articles)
return articles
def _landing_recent_posts(blocked):
if not blocked:
cached = _home_cache.get("posts")
if cached is not None:
return cached
posts = []
if "posts" in db.tables:
posts_table = get_table("posts")
fetch_limit = 24 if blocked else 6
raw_posts = list(
posts_table.find(deleted_at=None, order_by=["-created_at"], _limit=fetch_limit)
)
if blocked:
raw_posts = [p for p in raw_posts if p["user_uid"] not in blocked]
raw_posts = raw_posts[:6]
raw_posts = interleave_by_author(raw_posts)
if raw_posts:
post_uids = [p["uid"] for p in raw_posts]
author_uids = [p["user_uid"] for p in raw_posts]
authors = get_users_by_uids(author_uids)
comment_counts = get_comment_counts_by_post_uids(post_uids)
upvotes, downvotes = get_vote_counts(post_uids)
for p in raw_posts:
posts.append(
{
"post": p,
"author": authors.get(p["user_uid"]),
"time_ago": time_ago(p["created_at"]),
"comment_count": comment_counts.get(p["uid"], 0),
"stars": upvotes.get(p["uid"], 0) - downvotes.get(p["uid"], 0),
"slug": p.get("slug", "") or p["uid"],
}
)
if not blocked:
_home_cache.set("posts", posts)
return posts
def create_web_app() -> FastAPI:
from devplacepy_services.base.middleware import (
InternalCallCounterMiddleware,
RequestStatsMiddleware,
SanitizeErrorsMiddleware,
)
app = FastAPI(
title="DevPlace",
docs_url="/swagger",
redoc_url=None,
openapi_url="/openapi.json",
lifespan=lifespan,
)
app.add_middleware(SanitizeErrorsMiddleware)
app.add_middleware(InternalCallCounterMiddleware)
app.add_middleware(RequestStatsMiddleware)
app.mount(
"/static/uploads",
UploadStaticFiles(directory=str(UPLOADS_DIR), check_dir=False),
name="uploads",
)
app.mount(
f"/static/v{STATIC_VERSION}",
CachedStaticFiles(directory=str(STATIC_DIR)),
name="static_versioned",
)
app.mount("/static", FallbackStaticFiles(directory=str(STATIC_DIR)), name="static")
@app.exception_handler(404)
async def not_found(request: Request, exc):
if wants_json(request):
return json_error(404, "Not found")
seo_ctx = base_seo_context(
request,
title="Not Found - DevPlace",
description="The page you requested does not exist.",
robots="noindex",
)
return templates.TemplateResponse(
request,
"error.html",
{
**seo_ctx,
"request": request,
"error_code": 404,
"error_message": "Page not found",
},
status_code=404,
)
@app.exception_handler(500)
async def server_error(request: Request, exc):
logger.exception("500 error on %s %s", request.method, request.url.path)
if wants_json(request):
return json_error(500, "Internal server error")
seo_ctx = base_seo_context(
request,
title="Server Error - DevPlace",
description="Something went wrong.",
robots="noindex",
)
return templates.TemplateResponse(
request,
"error.html",
{
**seo_ctx,
"request": request,
"error_code": 500,
"error_message": "Internal server error",
},
status_code=500,
)
@app.exception_handler(RequestValidationError)
async def on_validation_error(request: Request, exc: RequestValidationError):
errors = [_friendly_error(e) for e in exc.errors()]
if wants_json(request):
fields: dict = {}
for raw, friendly in zip(exc.errors(), errors):
name = raw["loc"][-1] if raw.get("loc") else "_"
fields.setdefault(str(name), []).append(friendly)
return JSONResponse(
ValidationErrorOut(fields=fields, messages=errors).model_dump(mode="json"),
status_code=422,
)
path = request.url.path
page = _AUTH_FORM_PAGES.get(path)
if page is None and path.startswith("/auth/reset-password/"):
page = ("reset_password.html", "Set New Password")
if page:
template_name, title = page
context = {
**base_seo_context(request, title=title, robots="noindex,nofollow"),
"request": request,
"errors": errors,
}
try:
form = await request.form()
context.update({k: v for k, v in form.items() if isinstance(v, str)})
except Exception:
pass
if "token" in request.path_params:
context["token"] = request.path_params["token"]
return templates.TemplateResponse(
request, template_name, context, status_code=400
)
referer = safe_next(request.headers.get("referer"), "/feed")
return RedirectResponse(url=referer, status_code=303)
mount_ingress(app)
app.include_router(auth.router, prefix="/auth")
app.include_router(feed.router, prefix="/feed")
app.include_router(posts.router, prefix="/posts")
app.include_router(comments.router, prefix="/comments")
app.include_router(projects.router, prefix="/projects")
app.include_router(profile.router, prefix="/profile")
app.include_router(messages.router, prefix="/messages")
app.include_router(notifications.router, prefix="/notifications")
app.include_router(votes.router, prefix="/votes")
app.include_router(reactions.router, prefix="/reactions")
app.include_router(bookmarks.router, prefix="/bookmarks")
app.include_router(polls.router, prefix="/polls")
app.include_router(avatar.router, prefix="/avatar")
app.include_router(awards.router, prefix="/awards")
app.include_router(follow.router, prefix="/follow")
app.include_router(relations.router)
app.include_router(leaderboard.router, prefix="/leaderboard")
app.include_router(admin.router, prefix="/admin")
app.include_router(seo.router)
app.include_router(push.router)
app.include_router(docs.router)
app.include_router(issues.router, prefix="/issues")
app.include_router(gists.router, prefix="/gists")
app.include_router(news.router, prefix="/news")
app.include_router(uploads.router, prefix="/uploads")
app.include_router(media.router, prefix="/media")
app.include_router(proxy.router, prefix="/p")
app.include_router(devrant.router, prefix="/api")
app.include_router(dbapi.router, prefix="/dbapi")
app.include_router(game.router, prefix="/game")
@app.middleware("http")
async def await_pending_corrections(request: Request, call_next):
response = await call_next(request)
pending = request.scope.get(PENDING_SCOPE_KEY)
if pending:
await asyncio.gather(*pending, return_exceptions=True)
return response
@app.middleware("http")
async def add_security_headers(request: Request, call_next):
response = await call_next(request)
if not response.headers.get("X-Robots-Tag"):
response.headers["X-Robots-Tag"] = "index, follow"
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
if not request.url.path.startswith("/p/"):
response.headers["X-Frame-Options"] = "DENY"
response.headers["Content-Security-Policy"] = (
"object-src 'none'; base-uri 'self'; "
"frame-ancestors 'none'; form-action 'self'"
)
if request.url.path.startswith("/admin"):
response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
response.headers["Pragma"] = "no-cache"
response.headers["Expires"] = "0"
return response
@app.middleware("http")
async def rate_limit_middleware(request: Request, call_next):
if RATE_LIMIT_DISABLED:
return await call_next(request)
if request.method in (
"POST",
"PUT",
"DELETE",
"PATCH",
) and not request.url.path.startswith(("/openai", "/xmlrpc")):
settings = _hot_settings()
limit = _worker_rate_limit(settings["rate_limit_per_minute"])
window = settings["rate_limit_window_seconds"]
ip = client_ip(request, default="unknown")
now = time.time()
window_start = now - window
global _last_rate_sweep
if now - _last_rate_sweep >= RATE_SWEEP_INTERVAL:
_sweep_rate_limit_store(window_start)
_last_rate_sweep = now
timestamps = [t for t in _rate_limit_store.get(ip, ()) if t > window_start]
if len(timestamps) >= limit:
_rate_limit_store[ip] = timestamps
audit.record(
request,
"security.rate_limit.block",
result="denied",
summary=f"request from {ip} blocked by rate limit",
metadata={"ip": ip, "limit": limit, "window_seconds": window},
)
retry_after = {"Retry-After": str(window)}
if wants_json(request):
response = json_error(429, "Rate limit exceeded. Try again later.")
response.headers["Retry-After"] = str(window)
return response
return HTMLResponse(
"Rate limit exceeded. Try again later.",
status_code=429,
headers=retry_after,
)
timestamps.append(now)
_rate_limit_store[ip] = timestamps
return await call_next(request)
_MAINTENANCE_ALLOWED_PREFIXES = ("/static", "/avatar", "/auth", "/admin", "/openai")
@app.middleware("http")
async def maintenance_middleware(request: Request, call_next):
if _hot_settings()["maintenance_mode"] != "1":
return await call_next(request)
if request.url.path.startswith(_MAINTENANCE_ALLOWED_PREFIXES):
return await call_next(request)
user = get_current_user(request)
if user and user.get("role") == "Admin":
return await call_next(request)
message = get_setting(
"maintenance_message",
"DevPlace is undergoing scheduled maintenance. Please check back shortly.",
)
audit.record(
request,
"security.maintenance.block",
user=user,
result="denied",
summary="non-admin request blocked by maintenance mode",
)
if wants_json(request):
return json_error(503, message)
seo_ctx = base_seo_context(
request, title="Maintenance - DevPlace", description=message, robots="noindex"
)
return templates.TemplateResponse(
request,
"error.html",
{**seo_ctx, "request": request, "error_code": 503, "error_message": message},
status_code=503,
)
@app.middleware("http")
async def track_presence(request: Request, call_next):
path = request.url.path
if not path.startswith(("/static", "/avatar")):
user = get_current_user(request)
if user:
presence.touch(user["uid"])
return await call_next(request)
@app.middleware("http")
async def visit_statistics(request: Request, call_next):
from devplacepy.services.statistics.tracking import track_visit
response = await call_next(request)
track_visit(request, response.status_code)
return response
@app.middleware("http")
async def response_timing(request: Request, call_next):
start = time.perf_counter()
request.state.request_start = start
response = await call_next(request)
response.headers["X-Response-Time"] = f"{(time.perf_counter() - start) * 1000:.1f}ms"
return response
app.add_middleware(GZipMiddleware, minimum_size=512, compresslevel=6)
@app.get("/")
async def landing(request: Request):
user = get_current_user(request)
landing_articles = _landing_news()
blocked = get_blocked_uids(user["uid"]) if user else frozenset()
landing_posts = _landing_recent_posts(blocked)
base = site_url(request)
seo_ctx = base_seo_context(
request,
title="DevPlace - The Developer Social Network",
description="Track industry shifts. Discover bold releases. Share what you're building in an open, uncensored environment.",
breadcrumbs=[],
schemas=[website_schema(base)],
)
return respond(
request,
"landing.html",
{
**seo_ctx,
"request": request,
"user": user,
"is_authenticated": bool(user),
"user_post_count": get_user_post_count(user["uid"]) if user else 0,
"user_stars": get_user_stars(user["uid"]) if user else 0,
"landing_articles": landing_articles,
"landing_posts": landing_posts,
},
model=LandingOut,
)
return app
app = create_web_app()
+216
View File
@@ -0,0 +1,216 @@
import asyncio
import logging
from urllib.parse import urlparse
import httpx
import uuid_utils
import websockets
from starlette.datastructures import Headers
from starlette.requests import Request
from starlette.responses import Response
from starlette.routing import Route
from starlette.websockets import WebSocket
from devplacepy_services.base.config import service_url
from devplacepy_services.base.errors import error_response
from devplacepy_services.base.manifest import INGRESS_ROUTES as _INGRESS_SPECS
from devplacepy_services.base.proxy import HOP_HEADERS
logger = logging.getLogger(__name__)
INGRESS_ROUTES = [(route.prefix, route.service) for route in _INGRESS_SPECS]
TIMEOUTS = {
"xmlrpc": 120.0,
}
def _forward_headers_from_scope(scope) -> dict[str, str]:
original = Headers(scope=scope)
headers = {
k: v for k, v in original.items() if k.lower() not in HOP_HEADERS
}
headers["X-Request-Id"] = headers.get("X-Request-Id") or uuid_utils.uuid7().hex
headers["Accept-Encoding"] = "identity"
# Mirror nginx's `proxy_set_header Host $host` (Appendix F) so an
# upstream-generated absolute URL (redirect, url_for) reflects the
# public :10500 endpoint the browser is actually talking to, not the
# upstream service's own internal bind address/port.
original_host = original.get("host")
if original_host:
headers["Host"] = original_host
return headers
def _forward_headers(request: Request) -> dict[str, str]:
return _forward_headers_from_scope(request.scope)
_WS_HANDSHAKE_HEADERS = frozenset(
{
"sec-websocket-key",
"sec-websocket-version",
"sec-websocket-extensions",
"sec-websocket-protocol",
"sec-websocket-accept",
}
)
def _forward_ws_headers(scope) -> dict[str, str]:
headers = _forward_headers_from_scope(scope)
for key in list(headers):
if key.lower() in _WS_HANDSHAKE_HEADERS:
del headers[key]
return headers
def _target_path(scope, prefix: str) -> str:
# Starlette's Mount rewrites scope["root_path"] to the cumulative mount
# prefix but leaves scope["path"] as the FULL original request path (it
# does not strip the prefix) - so the full path alone is already the
# correct upstream path; concatenating root_path in front double-prefixes it.
return scope.get("path", "") or prefix
def _upstream_http_url(service: str, path: str, query: str) -> str:
base = service_url(service).rstrip("/")
url = f"{base}{path}"
if query:
url = f"{url}?{query}"
return url
def _upstream_ws_url(service: str, path: str, query: str) -> str:
parsed = urlparse(service_url(service))
scheme = "wss" if parsed.scheme == "https" else "ws"
netloc = parsed.netloc
url = f"{scheme}://{netloc}{path}"
if query:
url = f"{url}?{query}"
return url
class IngressProxy:
def __init__(self, prefix: str, service: str) -> None:
self.prefix = prefix
self.service = service
self.timeout = TIMEOUTS.get(service, 30.0)
async def __call__(self, scope, receive, send) -> None:
if scope["type"] == "http":
await self._proxy_http(scope, receive, send)
elif scope["type"] == "websocket":
await self._proxy_ws(scope, receive, send)
async def _proxy_http(self, scope, receive, send) -> None:
request = Request(scope, receive)
path = _target_path(scope, self.prefix)
query = scope.get("query_string", b"").decode()
url = _upstream_http_url(self.service, path, query)
body = await request.body()
headers = _forward_headers(request)
try:
async with httpx.AsyncClient(
timeout=self.timeout, follow_redirects=False
) as client:
upstream = await client.request(
request.method,
url,
headers=headers,
content=body,
)
except httpx.HTTPError as exc:
logger.warning("ingress %s upstream error: %s", self.prefix, exc)
response = error_response(
502, "Upstream service unavailable", "upstream_error"
)
await response(scope, receive, send)
return
out_headers = {
k: v
for k, v in upstream.headers.items()
if k.lower() not in HOP_HEADERS and k.lower() != "set-cookie"
}
response = Response(
content=upstream.content,
status_code=upstream.status_code,
headers=out_headers,
media_type=upstream.headers.get("content-type"),
)
for cookie in upstream.headers.get_list("set-cookie"):
response.headers.append("set-cookie", cookie)
await response(scope, receive, send)
async def _proxy_ws(self, scope, receive, send) -> None:
client_ws = WebSocket(scope, receive, send)
path = _target_path(scope, self.prefix)
query = scope.get("query_string", b"").decode()
upstream_url = _upstream_ws_url(self.service, path, query)
headers = _forward_ws_headers(scope)
await client_ws.accept()
try:
async with websockets.connect(
upstream_url,
open_timeout=10,
max_size=None,
additional_headers=headers,
) as upstream:
await _pump(client_ws, upstream)
except Exception as exc:
logger.debug("ingress ws %s failed: %s", self.prefix, exc)
try:
await client_ws.close(code=1011)
except Exception:
pass
async def _pump(client_ws: WebSocket, upstream) -> None:
async def client_to_upstream():
try:
while True:
message = await client_ws.receive()
if message["type"] == "websocket.disconnect":
break
if message.get("text") is not None:
await upstream.send(message["text"])
elif message.get("bytes") is not None:
await upstream.send(message["bytes"])
except Exception:
pass
finally:
await upstream.close()
async def upstream_to_client():
try:
async for message in upstream:
if isinstance(message, (bytes, bytearray)):
await client_ws.send_bytes(bytes(message))
else:
await client_ws.send_text(message)
except Exception:
pass
finally:
try:
await client_ws.close()
except Exception:
pass
await asyncio.gather(client_to_upstream(), upstream_to_client())
def mount_ingress(app) -> None:
for prefix, service in INGRESS_ROUTES:
proxy = IngressProxy(prefix, service)
# A bare hit on the prefix itself (no trailing slash, nothing after -
# e.g. a POST to /xmlrpc or GET /tools) never matches Mount's own
# path regex (it requires a "/" plus content after the prefix), so
# Starlette's router-level redirect_slashes fallback 307s it to
# "<prefix>/" before the Mount ever sees it. If the upstream service's
# own router registers an exact route at its mount root (as /tools
# and /xmlrpc both do), THAT redirects back to the bare prefix -
# an infinite loop between the two opposite trailing-slash
# conventions. Registering an explicit Route at the exact prefix
# bypasses Mount's regex/redirect fallback entirely for that one path.
app.router.routes.append(Route(prefix, endpoint=proxy, methods=None))
app.mount(prefix, proxy)
+21
View File
@@ -0,0 +1,21 @@
from devplacepy_services.base.health import health_router
from devplacepy_services.base.service import BaseMicroservice
from devplacepy_services.web.factory import create_web_app
class WebService(BaseMicroservice):
name = "web"
title = "Web"
default_port = 10500
workers = "auto"
stateful = False
depends_on = ["database", "pubsub"]
def build_app(self):
app = create_web_app()
app.include_router(health_router(self))
return app
_service = WebService()
app = _service.build_app()