Merge branch 'worktree-endpoints' into macros

This commit is contained in:
2026-03-11 23:48:30 +00:00
14 changed files with 756 additions and 256 deletions

View File

@@ -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
# ---------------------------------------------------------------------------

View File

@@ -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

View File

@@ -138,6 +138,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
;;

View File

@@ -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))))

View File

@@ -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):

View File

@@ -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):
@@ -1855,14 +1861,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

View File

@@ -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)})>"