Extend defhandler with :path/:method/:csrf, migrate 12 ref endpoints to SX

defhandler now supports keyword options for public route registration:
  (defhandler name :path "/..." :method :post :csrf false (&key) body)

Infrastructure: forms.sx parses options, HandlerDef stores path/method/csrf,
register_route_handlers() mounts path-based handlers as app routes.

New IO primitives (boundary.sx "Web interop" section): now, sleep,
request-form, request-json, request-header, request-content-type.

First migration: 12 reference API endpoints from Python f-string SX
to declarative .sx handlers in sx/sx/handlers/ref-api.sx.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-11 23:48:05 +00:00
parent 524c99e4ff
commit fba84540e2
10 changed files with 468 additions and 133 deletions

View File

@@ -103,6 +103,14 @@ def create_app() -> "Quart":
bp = register_pages(url_prefix="/")
app.register_blueprint(bp)
# Register SX-defined route handlers (defhandler with :path)
from shared.sx.handlers import register_route_handlers
n_routes = register_route_handlers(app, "sx")
if n_routes:
import logging
logging.getLogger("sx.handlers").info(
"Registered %d route handler(s) for sx", n_routes)
from shared.sx.pages import auto_mount_pages
auto_mount_pages(app, "sx")

View File

@@ -710,7 +710,15 @@ def register(url_prefix: str = "/") -> Blueprint:
return sx_response(f'(<> {sx_src} {oob_wire} {oob_comp})')
# ------------------------------------------------------------------
# Reference attribute detail API endpoints (for live demos)
# Reference API endpoints — remaining Python-only
#
# Most reference endpoints migrated to sx/sx/handlers/ref-api.sx.
# These remain because they need Python-specific features:
# - File upload access (request.files)
# - Dynamic all-params iteration
# - Stateful counters with non-200 responses
# - SSE streaming
# - Custom response headers
# ------------------------------------------------------------------
def _ref_wire(wire_id: str, sx_src: str) -> str:
@@ -718,105 +726,6 @@ def register(url_prefix: str = "/") -> Blueprint:
from sxc.pages.renders import _oob_code
return _oob_code(f"ref-wire-{wire_id}", sx_src)
@bp.get("/geography/hypermedia/reference/api/time")
async def ref_time():
from shared.sx.helpers import sx_response
now = datetime.now().strftime("%H:%M:%S")
sx_src = f'(span :class "text-stone-800 text-sm" "Server time: " (strong "{now}"))'
oob = _ref_wire("sx-get", sx_src)
return sx_response(f'(<> {sx_src} {oob})')
@csrf_exempt
@bp.post("/geography/hypermedia/reference/api/greet")
async def ref_greet():
from shared.sx.helpers import sx_response
form = await request.form
name = form.get("name") or "stranger"
sx_src = f'(span :class "text-stone-800 text-sm" "Hello, " (strong "{name}") "!")'
oob = _ref_wire("sx-post", sx_src)
return sx_response(f'(<> {sx_src} {oob})')
@csrf_exempt
@bp.put("/geography/hypermedia/reference/api/status")
async def ref_status():
from shared.sx.helpers import sx_response
form = await request.form
status = form.get("status", "unknown")
sx_src = f'(span :class "text-stone-700 text-sm" "Status: " (strong "{status}") " — updated via PUT")'
oob = _ref_wire("sx-put", sx_src)
return sx_response(f'(<> {sx_src} {oob})')
@csrf_exempt
@bp.patch("/geography/hypermedia/reference/api/theme")
async def ref_theme():
from shared.sx.helpers import sx_response
form = await request.form
theme = form.get("theme", "unknown")
sx_src = f'"{theme}"'
oob = _ref_wire("sx-patch", sx_src)
return sx_response(f'(<> {sx_src} {oob})')
@csrf_exempt
@bp.delete("/geography/hypermedia/reference/api/item/<item_id>")
async def ref_delete(item_id: str):
from shared.sx.helpers import sx_response
oob = _ref_wire("sx-delete", '""')
return sx_response(f'(<> {oob})')
@bp.get("/geography/hypermedia/reference/api/trigger-search")
async def ref_trigger_search():
from shared.sx.helpers import sx_response
q = request.args.get("q", "")
if not q:
sx_src = '(span :class "text-stone-400 text-sm" "Start typing to trigger a search.")'
else:
sx_src = f'(span :class "text-stone-800 text-sm" "Results for: " (strong "{q}"))'
oob = _ref_wire("sx-trigger", sx_src)
return sx_response(f'(<> {sx_src} {oob})')
@bp.get("/geography/hypermedia/reference/api/swap-item")
async def ref_swap_item():
from shared.sx.helpers import sx_response
now = datetime.now().strftime("%H:%M:%S")
sx_src = f'(div :class "text-sm text-violet-700" "New item (" "{now}" ")")'
oob = _ref_wire("sx-swap", sx_src)
return sx_response(f'(<> {sx_src} {oob})')
@bp.get("/geography/hypermedia/reference/api/oob")
async def ref_oob():
from shared.sx.helpers import sx_response
now = datetime.now().strftime("%H:%M:%S")
sx_src = (
f'(<>'
f' (span :class "text-emerald-700 text-sm" "Main updated at " "{now}")'
f' (div :id "ref-oob-side" :sx-swap-oob "innerHTML"'
f' (span :class "text-violet-700 text-sm" "OOB updated at " "{now}")))')
oob = _ref_wire("sx-swap-oob", sx_src)
return sx_response(f'(<> {sx_src} {oob})')
@bp.get("/geography/hypermedia/reference/api/select-page")
async def ref_select_page():
from shared.sx.helpers import sx_response
now = datetime.now().strftime("%H:%M:%S")
sx_src = (
f'(<>'
f' (div :id "the-header" (h3 "Page header — not selected"))'
f' (div :id "the-content"'
f' (span :class "text-emerald-700 text-sm"'
f' "This fragment was selected from a larger response. Time: " "{now}"))'
f' (div :id "the-footer" (p "Page footer — not selected")))')
oob = _ref_wire("sx-select", sx_src)
return sx_response(f'(<> {sx_src} {oob})')
@bp.get("/geography/hypermedia/reference/api/slow-echo")
async def ref_slow_echo():
from shared.sx.helpers import sx_response
await asyncio.sleep(0.8)
q = request.args.get("q", "")
sx_src = f'(span :class "text-stone-800 text-sm" "Echo: " (strong "{q}"))'
oob = _ref_wire("sx-sync", sx_src)
return sx_response(f'(<> {sx_src} {oob})')
@csrf_exempt
@bp.post("/geography/hypermedia/reference/api/upload-name")
async def ref_upload_name():
@@ -882,14 +791,6 @@ def register(url_prefix: str = "/") -> Blueprint:
oob = _ref_wire("sx-retry", sx_src)
return sx_response(f'(<> {sx_src} {oob})')
@bp.get("/geography/hypermedia/reference/api/prompt-echo")
async def ref_prompt_echo():
from shared.sx.helpers import sx_response
name = request.headers.get("SX-Prompt", "anonymous")
sx_src = f'(span :class "text-stone-800 text-sm" "Hello, " (strong "{name}") "!")'
oob = _ref_wire("sx-prompt", sx_src)
return sx_response(f'(<> {sx_src} {oob})')
@bp.get("/geography/hypermedia/reference/api/sse-time")
async def ref_sse_time():
async def generate():
@@ -1049,10 +950,4 @@ def register(url_prefix: str = "/") -> Blueprint:
resp.headers["SX-Retarget"] = "#ref-hdr-retarget-alt"
return resp
# --- Event demos ---
@bp.get("/geography/hypermedia/reference/api/error-500")
async def ref_error_500():
return Response("Server error", status=500, content_type="text/plain")
return bp

156
sx/sx/handlers/ref-api.sx Normal file
View File

@@ -0,0 +1,156 @@
;; Reference API endpoints — live demos for hypermedia attribute docs
;;
;; These replace the Python endpoints in bp/pages/routes.py.
;; Each defhandler with :path registers as a public route automatically.
;; --- sx-get demo: server time ---
(defhandler ref-time
:path "/geography/hypermedia/reference/api/time"
:method :get
(&key)
(let ((now (now "%H:%M:%S")))
(<>
(span :class "text-stone-800 text-sm" "Server time: " (strong now))
(~doc-oob-code :target-id "ref-wire-sx-get"
:text (str "(span :class \"text-stone-800 text-sm\" \"Server time: \" (strong \"" now "\"))")))))
;; --- sx-post demo: greet ---
(defhandler ref-greet
:path "/geography/hypermedia/reference/api/greet"
:method :post
:csrf false
(&key)
(let ((name (request-form "name" "stranger")))
(<>
(span :class "text-stone-800 text-sm" "Hello, " (strong name) "!")
(~doc-oob-code :target-id "ref-wire-sx-post"
:text (str "(span :class \"text-stone-800 text-sm\" \"Hello, \" (strong \"" name "\") \"!\")")))))
;; --- sx-put demo: status update ---
(defhandler ref-status
:path "/geography/hypermedia/reference/api/status"
:method :put
:csrf false
(&key)
(let ((status (request-form "status" "unknown")))
(<>
(span :class "text-stone-700 text-sm" "Status: " (strong status) " — updated via PUT")
(~doc-oob-code :target-id "ref-wire-sx-put"
:text (str "(span :class \"text-stone-700 text-sm\" \"Status: \" (strong \"" status "\") \" — updated via PUT\")")))))
;; --- sx-patch demo: theme ---
(defhandler ref-theme
:path "/geography/hypermedia/reference/api/theme"
:method :patch
:csrf false
(&key)
(let ((theme (request-form "theme" "unknown")))
(<>
theme
(~doc-oob-code :target-id "ref-wire-sx-patch"
:text (str "\"" theme "\"")))))
;; --- sx-delete demo ---
(defhandler ref-delete-item
:path "/geography/hypermedia/reference/api/item/<item_id>"
:method :delete
:csrf false
(&key)
(<>
(~doc-oob-code :target-id "ref-wire-sx-delete" :text "\"\"")))
;; --- sx-trigger demo: search ---
(defhandler ref-trigger-search
:path "/geography/hypermedia/reference/api/trigger-search"
:method :get
(&key)
(let ((q (request-arg "q" "")))
(let ((sx-text (if (= q "")
"(span :class \"text-stone-400 text-sm\" \"Start typing to trigger a search.\")"
(str "(span :class \"text-stone-800 text-sm\" \"Results for: \" (strong \"" q "\"))"))))
(<>
(if (= q "")
(span :class "text-stone-400 text-sm" "Start typing to trigger a search.")
(span :class "text-stone-800 text-sm" "Results for: " (strong q)))
(~doc-oob-code :target-id "ref-wire-sx-trigger" :text sx-text)))))
;; --- sx-swap demo ---
(defhandler ref-swap-item
:path "/geography/hypermedia/reference/api/swap-item"
:method :get
(&key)
(let ((now (now "%H:%M:%S")))
(<>
(div :class "text-sm text-violet-700" (str "New item (" now ")"))
(~doc-oob-code :target-id "ref-wire-sx-swap"
:text (str "(div :class \"text-sm text-violet-700\" \"New item (" now ")\")")))))
;; --- sx-swap-oob demo ---
(defhandler ref-oob
:path "/geography/hypermedia/reference/api/oob"
:method :get
(&key)
(let ((now (now "%H:%M:%S")))
(<>
(span :class "text-emerald-700 text-sm" "Main updated at " now)
(div :id "ref-oob-side" :sx-swap-oob "innerHTML"
(span :class "text-violet-700 text-sm" "OOB updated at " now))
(~doc-oob-code :target-id "ref-wire-sx-swap-oob"
:text (str "(<> (span ... \"" now "\") (div :id \"ref-oob-side\" :sx-swap-oob \"innerHTML\" ...))")))))
;; --- sx-select demo ---
(defhandler ref-select-page
:path "/geography/hypermedia/reference/api/select-page"
:method :get
(&key)
(let ((now (now "%H:%M:%S")))
(<>
(div :id "the-header" (h3 "Page header — not selected"))
(div :id "the-content"
(span :class "text-emerald-700 text-sm"
"This fragment was selected from a larger response. Time: " now))
(div :id "the-footer" (p "Page footer — not selected"))
(~doc-oob-code :target-id "ref-wire-sx-select"
:text (str "(<> (div :id \"the-header\" ...) (div :id \"the-content\" ... \"" now "\") (div :id \"the-footer\" ...))")))))
;; --- sx-sync demo: slow echo ---
(defhandler ref-slow-echo
:path "/geography/hypermedia/reference/api/slow-echo"
:method :get
(&key)
(let ((q (request-arg "q" "")))
(sleep 800)
(<>
(span :class "text-stone-800 text-sm" "Echo: " (strong q))
(~doc-oob-code :target-id "ref-wire-sx-sync"
:text (str "(span :class \"text-stone-800 text-sm\" \"Echo: \" (strong \"" q "\"))")))))
;; --- sx-prompt demo ---
(defhandler ref-prompt-echo
:path "/geography/hypermedia/reference/api/prompt-echo"
:method :get
(&key)
(let ((name (request-header "SX-Prompt" "anonymous")))
(<>
(span :class "text-stone-800 text-sm" "Hello, " (strong name) "!")
(~doc-oob-code :target-id "ref-wire-sx-prompt"
:text (str "(span :class \"text-stone-800 text-sm\" \"Hello, \" (strong \"" name "\") \"!\")")))))
;; --- Error demo ---
(defhandler ref-error-500
:path "/geography/hypermedia/reference/api/error-500"
:method :get
(&key)
(abort 500 "Server error"))