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:
@@ -215,6 +215,56 @@ def create_handler_blueprint(service_name: str) -> Any:
|
||||
return bp
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public route registration — handlers with :path get mounted as routes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def register_route_handlers(app_or_bp: Any, service_name: str) -> int:
|
||||
"""Register public routes for all handlers with :path defined.
|
||||
|
||||
Returns the number of routes registered.
|
||||
"""
|
||||
from quart import Response, request
|
||||
from shared.browser.app.csrf import csrf_exempt
|
||||
|
||||
handlers = get_all_handlers(service_name)
|
||||
count = 0
|
||||
|
||||
for name, hdef in handlers.items():
|
||||
if not hdef.is_route:
|
||||
continue
|
||||
|
||||
# Capture hdef in closure
|
||||
_hdef = hdef
|
||||
|
||||
async def _route_view(_h=_hdef, **path_kwargs):
|
||||
from shared.sx.helpers import sx_response
|
||||
args = dict(request.args)
|
||||
args.update(path_kwargs)
|
||||
result = await execute_handler(_h, service_name, args=args)
|
||||
return sx_response(result)
|
||||
|
||||
endpoint = f"sx_route_{name}"
|
||||
view_fn = _route_view
|
||||
|
||||
if not _hdef.csrf:
|
||||
view_fn = csrf_exempt(view_fn)
|
||||
|
||||
method = _hdef.method.lower()
|
||||
route_reg = getattr(app_or_bp, method, None)
|
||||
if route_reg is None:
|
||||
logger.warning("Unsupported HTTP method %s for handler %s",
|
||||
_hdef.method, name)
|
||||
continue
|
||||
|
||||
route_reg(_hdef.path, endpoint=endpoint)(view_fn)
|
||||
logger.info("Registered route %s %s → handler:%s",
|
||||
_hdef.method.upper(), _hdef.path, name)
|
||||
count += 1
|
||||
|
||||
return count
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Direct app mount — replaces per-service fragment blueprint boilerplate
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -297,6 +297,81 @@ async def _io_g(
|
||||
return getattr(g, key, None)
|
||||
|
||||
|
||||
@register_io_handler("now")
|
||||
async def _io_now(
|
||||
args: list[Any], kwargs: dict[str, Any], ctx: RequestContext
|
||||
) -> str:
|
||||
"""``(now)`` or ``(now "%H:%M:%S")`` → formatted timestamp string."""
|
||||
from datetime import datetime
|
||||
fmt = str(args[0]) if args else None
|
||||
dt = datetime.now()
|
||||
return dt.strftime(fmt) if fmt else dt.isoformat()
|
||||
|
||||
|
||||
@register_io_handler("sleep")
|
||||
async def _io_sleep(
|
||||
args: list[Any], kwargs: dict[str, Any], ctx: RequestContext
|
||||
) -> Any:
|
||||
"""``(sleep 800)`` → pause for 800ms."""
|
||||
import asyncio
|
||||
from .types import NIL
|
||||
if not args:
|
||||
raise ValueError("sleep requires milliseconds")
|
||||
ms = int(args[0])
|
||||
await asyncio.sleep(ms / 1000.0)
|
||||
return NIL
|
||||
|
||||
|
||||
@register_io_handler("request-form")
|
||||
async def _io_request_form(
|
||||
args: list[Any], kwargs: dict[str, Any], ctx: RequestContext
|
||||
) -> Any:
|
||||
"""``(request-form "name" default?)`` → read a form field."""
|
||||
if not args:
|
||||
raise ValueError("request-form requires a field name")
|
||||
from quart import request
|
||||
from .types import NIL
|
||||
name = str(args[0])
|
||||
default = args[1] if len(args) > 1 else NIL
|
||||
form = await request.form
|
||||
return form.get(name, default)
|
||||
|
||||
|
||||
@register_io_handler("request-json")
|
||||
async def _io_request_json(
|
||||
args: list[Any], kwargs: dict[str, Any], ctx: RequestContext
|
||||
) -> Any:
|
||||
"""``(request-json)`` → JSON body as dict, or nil."""
|
||||
from quart import request
|
||||
from .types import NIL
|
||||
data = await request.get_json(silent=True)
|
||||
return data if data is not None else NIL
|
||||
|
||||
|
||||
@register_io_handler("request-header")
|
||||
async def _io_request_header(
|
||||
args: list[Any], kwargs: dict[str, Any], ctx: RequestContext
|
||||
) -> Any:
|
||||
"""``(request-header "name" default?)`` → request header value."""
|
||||
if not args:
|
||||
raise ValueError("request-header requires a header name")
|
||||
from quart import request
|
||||
from .types import NIL
|
||||
name = str(args[0])
|
||||
default = args[1] if len(args) > 1 else NIL
|
||||
return request.headers.get(name, default)
|
||||
|
||||
|
||||
@register_io_handler("request-content-type")
|
||||
async def _io_request_content_type(
|
||||
args: list[Any], kwargs: dict[str, Any], ctx: RequestContext
|
||||
) -> Any:
|
||||
"""``(request-content-type)`` → content-type string or nil."""
|
||||
from quart import request
|
||||
from .types import NIL
|
||||
return request.content_type or NIL
|
||||
|
||||
|
||||
@register_io_handler("csrf-token")
|
||||
async def _io_csrf_token(
|
||||
args: list[Any], kwargs: dict[str, Any], ctx: RequestContext
|
||||
|
||||
@@ -126,6 +126,57 @@
|
||||
"list" "dict" "sx-source"))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Web interop — reading non-SX request formats
|
||||
;;
|
||||
;; SX's native wire format is SX (text/sx). These primitives bridge to
|
||||
;; legacy web formats: HTML form encoding, JSON bodies, HTTP headers.
|
||||
;; They're useful for interop but not fundamental to SX-to-SX communication.
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(define-io-primitive "now"
|
||||
:params (&rest format)
|
||||
:returns "string"
|
||||
:async true
|
||||
:doc "Current timestamp. Optional format string (strftime). Default ISO 8601."
|
||||
:context :request)
|
||||
|
||||
(define-io-primitive "sleep"
|
||||
:params (ms)
|
||||
:returns "nil"
|
||||
:async true
|
||||
:doc "Pause execution for ms milliseconds. For demos and testing."
|
||||
:context :request)
|
||||
|
||||
(define-io-primitive "request-form"
|
||||
:params (name &rest default)
|
||||
:returns "any"
|
||||
:async true
|
||||
:doc "Read a form field from a POST/PUT/PATCH request body."
|
||||
:context :request)
|
||||
|
||||
(define-io-primitive "request-json"
|
||||
:params ()
|
||||
:returns "dict?"
|
||||
:async true
|
||||
:doc "Read JSON body from the current request, or nil if not JSON."
|
||||
:context :request)
|
||||
|
||||
(define-io-primitive "request-header"
|
||||
:params (name &rest default)
|
||||
:returns "string?"
|
||||
:async true
|
||||
:doc "Read a request header value by name."
|
||||
:context :request)
|
||||
|
||||
(define-io-primitive "request-content-type"
|
||||
:params ()
|
||||
:returns "string?"
|
||||
:async true
|
||||
:doc "Content-Type of the current request."
|
||||
:context :request)
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Tier 3: Signal primitives — reactive state for islands
|
||||
;;
|
||||
|
||||
@@ -38,17 +38,65 @@
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; defhandler — (defhandler name (&key param...) body)
|
||||
;; defhandler — (defhandler name [:path "..." :method :get :csrf false] (&key param...) body)
|
||||
;;
|
||||
;; Keyword options between name and params list:
|
||||
;; :path — public route path (string). Without :path, handler is internal-only.
|
||||
;; :method — HTTP method (keyword: :get :post :put :patch :delete). Default :get.
|
||||
;; :csrf — CSRF protection (boolean). Default true; set false for POST/PUT etc.
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(define parse-handler-args
|
||||
(fn ((args :as list))
|
||||
"Parse defhandler args after the name symbol.
|
||||
Scans for :keyword value option pairs, then a list (params), then body.
|
||||
Returns dict with keys: opts, params, body."
|
||||
(let ((opts {})
|
||||
(params (list))
|
||||
(body nil)
|
||||
(i 0)
|
||||
(n (len args))
|
||||
(done false))
|
||||
(for-each
|
||||
(fn (idx)
|
||||
(when (and (not done) (= idx i))
|
||||
(let ((arg (nth args idx)))
|
||||
(cond
|
||||
;; keyword-value pair → consume two items
|
||||
(= (type-of arg) "keyword")
|
||||
(do
|
||||
(when (< (+ idx 1) n)
|
||||
(let ((val (nth args (+ idx 1))))
|
||||
;; For :method, extract keyword name; for :csrf, keep as-is
|
||||
(dict-set! opts (keyword-name arg)
|
||||
(if (= (type-of val) "keyword")
|
||||
(keyword-name val)
|
||||
val))))
|
||||
(set! i (+ idx 2)))
|
||||
;; list → params, next element is body
|
||||
(= (type-of arg) "list")
|
||||
(do
|
||||
(set! params (parse-key-params arg))
|
||||
(when (< (+ idx 1) n)
|
||||
(set! body (nth args (+ idx 1))))
|
||||
(set! done true))
|
||||
;; anything else → no explicit params, this is body
|
||||
:else
|
||||
(do
|
||||
(set! body arg)
|
||||
(set! done true))))))
|
||||
(range 0 n))
|
||||
(dict :opts opts :params params :body body))))
|
||||
|
||||
(define sf-defhandler
|
||||
(fn ((args :as list) (env :as dict))
|
||||
(let ((name-sym (first args))
|
||||
(params-raw (nth args 1))
|
||||
(body (nth args 2))
|
||||
(name (symbol-name name-sym))
|
||||
(params (parse-key-params params-raw)))
|
||||
(let ((hdef (make-handler-def name params body env)))
|
||||
(let ((name-sym (first args))
|
||||
(name (symbol-name name-sym))
|
||||
(parsed (parse-handler-args (rest args)))
|
||||
(opts (get parsed "opts"))
|
||||
(params (get parsed "params"))
|
||||
(body (get parsed "body")))
|
||||
(let ((hdef (make-handler-def name params body env opts)))
|
||||
(env-set! env (str "handler:" name) hdef)
|
||||
hdef))))
|
||||
|
||||
|
||||
@@ -222,8 +222,14 @@ def make_macro(params, rest_param, body, env, name=None):
|
||||
closure=dict(env), name=name)
|
||||
|
||||
|
||||
def make_handler_def(name, params, body, env):
|
||||
return HandlerDef(name=name, params=list(params), body=body, closure=dict(env))
|
||||
def make_handler_def(name, params, body, env, opts=None):
|
||||
path = opts.get('path') if opts else None
|
||||
method = str(opts.get('method', 'get')) if opts else 'get'
|
||||
csrf = opts.get('csrf', True) if opts else True
|
||||
if isinstance(csrf, str):
|
||||
csrf = csrf.lower() not in ('false', 'nil', 'no')
|
||||
return HandlerDef(name=name, params=list(params), body=body, closure=dict(env),
|
||||
path=path, method=method.lower(), csrf=csrf)
|
||||
|
||||
|
||||
def make_query_def(name, params, doc, body, env):
|
||||
|
||||
@@ -181,8 +181,14 @@ def make_macro(params, rest_param, body, env, name=None):
|
||||
closure=dict(env), name=name)
|
||||
|
||||
|
||||
def make_handler_def(name, params, body, env):
|
||||
return HandlerDef(name=name, params=list(params), body=body, closure=dict(env))
|
||||
def make_handler_def(name, params, body, env, opts=None):
|
||||
path = opts.get('path') if opts else None
|
||||
method = str(opts.get('method', 'get')) if opts else 'get'
|
||||
csrf = opts.get('csrf', True) if opts else True
|
||||
if isinstance(csrf, str):
|
||||
csrf = csrf.lower() not in ('false', 'nil', 'no')
|
||||
return HandlerDef(name=name, params=list(params), body=body, closure=dict(env),
|
||||
path=path, method=method.lower(), csrf=csrf)
|
||||
|
||||
|
||||
def make_query_def(name, params, doc, body, env):
|
||||
@@ -1776,14 +1782,43 @@ def parse_key_params(params_expr):
|
||||
params.append(name)
|
||||
return params
|
||||
|
||||
# parse-handler-args
|
||||
def parse_handler_args(args):
|
||||
_cells = {}
|
||||
'Parse defhandler args after the name symbol.\n Scans for :keyword value option pairs, then a list (params), then body.\n Returns dict with keys: opts, params, body.'
|
||||
opts = {}
|
||||
_cells['params'] = []
|
||||
_cells['body'] = NIL
|
||||
_cells['i'] = 0
|
||||
n = len(args)
|
||||
_cells['done'] = False
|
||||
for idx in range(0, n):
|
||||
if sx_truthy(((not sx_truthy(_cells['done'])) if not sx_truthy((not sx_truthy(_cells['done']))) else (idx == _cells['i']))):
|
||||
arg = nth(args, idx)
|
||||
if sx_truthy((type_of(arg) == 'keyword')):
|
||||
if sx_truthy(((idx + 1) < n)):
|
||||
val = nth(args, (idx + 1))
|
||||
opts[keyword_name(arg)] = (keyword_name(val) if sx_truthy((type_of(val) == 'keyword')) else val)
|
||||
_cells['i'] = (idx + 2)
|
||||
elif sx_truthy((type_of(arg) == 'list')):
|
||||
_cells['params'] = parse_key_params(arg)
|
||||
if sx_truthy(((idx + 1) < n)):
|
||||
_cells['body'] = nth(args, (idx + 1))
|
||||
_cells['done'] = True
|
||||
else:
|
||||
_cells['body'] = arg
|
||||
_cells['done'] = True
|
||||
return {'opts': opts, 'params': _cells['params'], 'body': _cells['body']}
|
||||
|
||||
# sf-defhandler
|
||||
def sf_defhandler(args, env):
|
||||
name_sym = first(args)
|
||||
params_raw = nth(args, 1)
|
||||
body = nth(args, 2)
|
||||
name = symbol_name(name_sym)
|
||||
params = parse_key_params(params_raw)
|
||||
hdef = make_handler_def(name, params, body, env)
|
||||
parsed = parse_handler_args(rest(args))
|
||||
opts = get(parsed, 'opts')
|
||||
params = get(parsed, 'params')
|
||||
body = get(parsed, 'body')
|
||||
hdef = make_handler_def(name, params, body, env, opts)
|
||||
env[sx_str('handler:', name)] = hdef
|
||||
return hdef
|
||||
|
||||
|
||||
@@ -221,18 +221,29 @@ class Island:
|
||||
|
||||
@dataclass
|
||||
class HandlerDef:
|
||||
"""A declarative fragment handler defined in an .sx file.
|
||||
"""A declarative handler defined in an .sx file.
|
||||
|
||||
Created by ``(defhandler name (&key param...) body)``.
|
||||
The body is evaluated in a sandboxed environment with only
|
||||
s-expression primitives available.
|
||||
Created by ``(defhandler name :path "/..." :method :get (&key param...) body)``.
|
||||
|
||||
When ``path`` is set, the handler is registered as a public route.
|
||||
When ``path`` is None, it's an internal fragment handler (legacy behaviour).
|
||||
"""
|
||||
name: str
|
||||
params: list[str] # keyword parameter names
|
||||
body: Any # unevaluated s-expression body
|
||||
closure: dict[str, Any] = field(default_factory=dict)
|
||||
path: str | None = None # public route path (None = internal fragment only)
|
||||
method: str = "get" # HTTP method (get, post, put, patch, delete)
|
||||
csrf: bool = True # CSRF protection enabled
|
||||
|
||||
@property
|
||||
def is_route(self) -> bool:
|
||||
"""True if this handler has a public route path."""
|
||||
return self.path is not None
|
||||
|
||||
def __repr__(self):
|
||||
if self.path:
|
||||
return f"<handler:{self.name} {self.method.upper()} {self.path}>"
|
||||
return f"<handler:{self.name}({', '.join(self.params)})>"
|
||||
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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
156
sx/sx/handlers/ref-api.sx
Normal 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"))
|
||||
Reference in New Issue
Block a user