diff --git a/shared/sx/handlers.py b/shared/sx/handlers.py index 31f8772..23a29d6 100644 --- a/shared/sx/handlers.py +++ b/shared/sx/handlers.py @@ -239,10 +239,19 @@ def register_route_handlers(app_or_bp: Any, service_name: str) -> int: async def _route_view(_h=_hdef, **path_kwargs): from shared.sx.helpers import sx_response + from shared.sx.primitives_io import reset_response_meta, get_response_meta + reset_response_meta() args = dict(request.args) args.update(path_kwargs) result = await execute_handler(_h, service_name, args=args) - return sx_response(result) + resp = sx_response(result) + meta = get_response_meta() + if meta: + if meta.get("status"): + resp.status_code = meta["status"] + for k, v in meta.get("headers", {}).items(): + resp.headers[k] = v + return resp endpoint = f"sx_route_{name}" view_fn = _route_view diff --git a/shared/sx/primitives_io.py b/shared/sx/primitives_io.py index 79898b5..1fa1a61 100644 --- a/shared/sx/primitives_io.py +++ b/shared/sx/primitives_io.py @@ -46,6 +46,13 @@ _handler_service: contextvars.ContextVar[Any] = contextvars.ContextVar( "_handler_service", default=None ) +_response_meta: contextvars.ContextVar[dict | None] = contextvars.ContextVar( + "_response_meta", default=None +) + +# Ephemeral per-process state — resets on restart. For demos/testing only. +_ephemeral_state: dict[str, Any] = {} + def set_handler_service(service_obj: Any) -> None: """Bind the local domain service for ``(service ...)`` primitive calls.""" @@ -57,6 +64,16 @@ def get_handler_service() -> Any: return _handler_service.get(None) +def reset_response_meta() -> None: + """Reset response meta for a new request.""" + _response_meta.set(None) + + +def get_response_meta() -> dict | None: + """Get response meta (headers/status) set by handler IO primitives.""" + return _response_meta.get(None) + + class RequestContext: """Per-request context provided to I/O primitives.""" __slots__ = ("user", "is_htmx", "extras") @@ -372,6 +389,105 @@ async def _io_request_content_type( return request.content_type or NIL +@register_io_handler("request-args-all") +async def _io_request_args_all( + args: list[Any], kwargs: dict[str, Any], ctx: RequestContext +) -> dict: + """``(request-args-all)`` → all query params as dict.""" + from quart import request + return dict(request.args) + + +@register_io_handler("request-form-all") +async def _io_request_form_all( + args: list[Any], kwargs: dict[str, Any], ctx: RequestContext +) -> dict: + """``(request-form-all)`` → all form fields as dict.""" + from quart import request + form = await request.form + return dict(form) + + +@register_io_handler("request-headers-all") +async def _io_request_headers_all( + args: list[Any], kwargs: dict[str, Any], ctx: RequestContext +) -> dict: + """``(request-headers-all)`` → all headers as dict (lowercase keys).""" + from quart import request + return {k.lower(): v for k, v in request.headers} + + +@register_io_handler("request-file-name") +async def _io_request_file_name( + args: list[Any], kwargs: dict[str, Any], ctx: RequestContext +) -> Any: + """``(request-file-name "field")`` → filename or nil.""" + if not args: + raise ValueError("request-file-name requires a field name") + from quart import request + from .types import NIL + files = await request.files + f = files.get(str(args[0])) + return f.filename if f else NIL + + +@register_io_handler("set-response-header") +async def _io_set_response_header( + args: list[Any], kwargs: dict[str, Any], ctx: RequestContext +) -> Any: + """``(set-response-header "Name" "value")`` → set on response after handler.""" + if len(args) < 2: + raise ValueError("set-response-header requires name and value") + from .types import NIL + meta = _response_meta.get(None) + if meta is None: + meta = {"headers": {}, "status": None} + _response_meta.set(meta) + meta["headers"][str(args[0])] = str(args[1]) + return NIL + + +@register_io_handler("set-response-status") +async def _io_set_response_status( + args: list[Any], kwargs: dict[str, Any], ctx: RequestContext +) -> Any: + """``(set-response-status 503)`` → set status code on response.""" + if not args: + raise ValueError("set-response-status requires a status code") + from .types import NIL + meta = _response_meta.get(None) + if meta is None: + meta = {"headers": {}, "status": None} + _response_meta.set(meta) + meta["status"] = int(args[0]) + return NIL + + +@register_io_handler("state-get") +async def _io_state_get( + args: list[Any], kwargs: dict[str, Any], ctx: RequestContext +) -> Any: + """``(state-get "key" default?)`` → read from ephemeral state.""" + if not args: + raise ValueError("state-get requires a key") + from .types import NIL + key = str(args[0]) + default = args[1] if len(args) > 1 else NIL + return _ephemeral_state.get(key, default) + + +@register_io_handler("state-set!") +async def _io_state_set( + args: list[Any], kwargs: dict[str, Any], ctx: RequestContext +) -> Any: + """``(state-set! "key" value)`` → write to ephemeral state.""" + if len(args) < 2: + raise ValueError("state-set! requires key and value") + from .types import NIL + _ephemeral_state[str(args[0])] = args[1] + return NIL + + @register_io_handler("csrf-token") async def _io_csrf_token( args: list[Any], kwargs: dict[str, Any], ctx: RequestContext diff --git a/shared/sx/ref/boundary.sx b/shared/sx/ref/boundary.sx index ccb6cf6..8a52c07 100644 --- a/shared/sx/ref/boundary.sx +++ b/shared/sx/ref/boundary.sx @@ -176,6 +176,66 @@ :doc "Content-Type of the current request." :context :request) +(define-io-primitive "request-args-all" + :params () + :returns "dict" + :async true + :doc "All query string parameters as a dict." + :context :request) + +(define-io-primitive "request-form-all" + :params () + :returns "dict" + :async true + :doc "All form fields as a dict." + :context :request) + +(define-io-primitive "request-headers-all" + :params () + :returns "dict" + :async true + :doc "All request headers as a dict (lowercase keys)." + :context :request) + +(define-io-primitive "request-file-name" + :params (field-name) + :returns "string?" + :async true + :doc "Filename of an uploaded file by field name, or nil." + :context :request) + +;; Response manipulation + +(define-io-primitive "set-response-header" + :params (name value) + :returns "nil" + :async true + :doc "Set a response header. Applied after handler returns." + :context :request) + +(define-io-primitive "set-response-status" + :params (status) + :returns "nil" + :async true + :doc "Set the HTTP response status code. Applied after handler returns." + :context :request) + +;; Ephemeral state — per-process, resets on restart + +(define-io-primitive "state-get" + :params (key &rest default) + :returns "any" + :async true + :doc "Read from ephemeral per-process state dict." + :context :request) + +(define-io-primitive "state-set!" + :params (key value) + :returns "nil" + :async true + :doc "Write to ephemeral per-process state dict." + :context :request) + ;; -------------------------------------------------------------------------- ;; Tier 3: Signal primitives — reactive state for islands diff --git a/shared/sx/ref/forms.sx b/shared/sx/ref/forms.sx index afe7e55..1bef80b 100644 --- a/shared/sx/ref/forms.sx +++ b/shared/sx/ref/forms.sx @@ -38,12 +38,13 @@ ;; -------------------------------------------------------------------------- -;; defhandler — (defhandler name [:path "..." :method :get :csrf false] (&key param...) body) +;; defhandler — (defhandler name [:path "..." :method :get :csrf false :returns "element"] (&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. +;; :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. +;; :returns — return type annotation (types.sx vocabulary). Default "element". ;; -------------------------------------------------------------------------- (define parse-handler-args diff --git a/shared/sx/ref/platform_py.py b/shared/sx/ref/platform_py.py index a999f92..cbb314e 100644 --- a/shared/sx/ref/platform_py.py +++ b/shared/sx/ref/platform_py.py @@ -226,10 +226,11 @@ 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 + returns = str(opts.get('returns', 'element')) if opts else 'element' 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) + path=path, method=method.lower(), csrf=csrf, returns=returns) def make_query_def(name, params, doc, body, env): diff --git a/shared/sx/ref/sx_ref.py b/shared/sx/ref/sx_ref.py index 4ee5a8a..06442de 100644 --- a/shared/sx/ref/sx_ref.py +++ b/shared/sx/ref/sx_ref.py @@ -185,10 +185,11 @@ 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 + returns = str(opts.get('returns', 'element')) if opts else 'element' 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) + path=path, method=method.lower(), csrf=csrf, returns=returns) def make_query_def(name, params, doc, body, env): diff --git a/shared/sx/types.py b/shared/sx/types.py index 426b004..8a5bcfd 100644 --- a/shared/sx/types.py +++ b/shared/sx/types.py @@ -235,6 +235,7 @@ class HandlerDef: 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 + returns: str = "element" # return type (types.sx vocabulary) @property def is_route(self) -> bool: diff --git a/sx/bp/pages/routes.py b/sx/bp/pages/routes.py index e52fc25..6222597 100644 --- a/sx/bp/pages/routes.py +++ b/sx/bp/pages/routes.py @@ -713,84 +713,9 @@ def register(url_prefix: str = "/") -> Blueprint: # 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 + # SSE stays in Python — fundamentally different paradigm (async generator). # ------------------------------------------------------------------ - def _ref_wire(wire_id: str, sx_src: str) -> str: - """Build OOB swap showing the wire response text.""" - from sxc.pages.renders import _oob_code - return _oob_code(f"ref-wire-{wire_id}", sx_src) - - @csrf_exempt - @bp.post("/geography/hypermedia/reference/api/upload-name") - async def ref_upload_name(): - from shared.sx.helpers import sx_response - files = await request.files - f = files.get("file") - name = f.filename if f else "(no file)" - sx_src = f'(span :class "text-stone-800 text-sm" "Received: " (strong "{name}"))' - oob = _ref_wire("sx-encoding", sx_src) - return sx_response(f'(<> {sx_src} {oob})') - - @bp.get("/geography/hypermedia/reference/api/echo-headers") - async def ref_echo_headers(): - from shared.sx.helpers import sx_response - custom = [(k, v) for k, v in request.headers if k.lower().startswith("x-")] - if not custom: - sx_src = '(span :class "text-stone-400 text-sm" "No custom headers received.")' - else: - items = " ".join( - f'(li (strong "{k}") ": " "{v}")' for k, v in custom) - sx_src = f'(ul :class "text-sm text-stone-700 space-y-1" {items})' - oob = _ref_wire("sx-headers", sx_src) - return sx_response(f'(<> {sx_src} {oob})') - - @bp.get("/geography/hypermedia/reference/api/echo-vals") - async def ref_echo_vals_get(): - from shared.sx.helpers import sx_response - vals = list(request.args.items()) - if not vals: - sx_src = '(span :class "text-stone-400 text-sm" "No values received.")' - else: - items = " ".join( - f'(li (strong "{k}") ": " "{v}")' for k, v in vals) - sx_src = f'(ul :class "text-sm text-stone-700 space-y-1" {items})' - oob_include = _ref_wire("sx-include", sx_src) - return sx_response(f'(<> {sx_src} {oob_include})') - - @csrf_exempt - @bp.post("/geography/hypermedia/reference/api/echo-vals") - async def ref_echo_vals_post(): - from shared.sx.helpers import sx_response - form = await request.form - vals = list(form.items()) - if not vals: - sx_src = '(span :class "text-stone-400 text-sm" "No values received.")' - else: - items = " ".join( - f'(li (strong "{k}") ": " "{v}")' for k, v in vals) - sx_src = f'(ul :class "text-sm text-stone-700 space-y-1" {items})' - oob = _ref_wire("sx-vals", sx_src) - return sx_response(f'(<> {sx_src} {oob})') - - _ref_flaky = {"n": 0} - - @bp.get("/geography/hypermedia/reference/api/flaky") - async def ref_flaky(): - from shared.sx.helpers import sx_response - _ref_flaky["n"] += 1 - n = _ref_flaky["n"] - if n % 3 != 0: - return Response("", status=503, content_type="text/plain") - sx_src = f'(span :class "text-emerald-700 text-sm" "Success on attempt " "{n}" "!")' - oob = _ref_wire("sx-retry", 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(): @@ -930,24 +855,4 @@ def register(url_prefix: str = "/") -> Blueprint: ) return sx_response(sx_src) - # --- Header demos --- - - @bp.get("/geography/hypermedia/reference/api/trigger-event") - async def ref_trigger_event(): - from shared.sx.helpers import sx_response - now = datetime.now().strftime("%H:%M:%S") - sx_src = f'(span :class "text-stone-800 text-sm" "Loaded at " (strong "{now}") " — check the border!")' - resp = sx_response(sx_src) - resp.headers["SX-Trigger"] = "showNotice" - return resp - - @bp.get("/geography/hypermedia/reference/api/retarget") - async def ref_retarget(): - from shared.sx.helpers import sx_response - now = datetime.now().strftime("%H:%M:%S") - sx_src = f'(span :class "text-violet-700 text-sm" "Retargeted at " (strong "{now}"))' - resp = sx_response(sx_src) - resp.headers["SX-Retarget"] = "#ref-hdr-retarget-alt" - return resp - return bp diff --git a/sx/sx/handlers/ref-api.sx b/sx/sx/handlers/ref-api.sx index afeb5a4..19826b2 100644 --- a/sx/sx/handlers/ref-api.sx +++ b/sx/sx/handlers/ref-api.sx @@ -8,6 +8,7 @@ (defhandler ref-time :path "/geography/hypermedia/reference/api/time" :method :get + :returns "element" (&key) (let ((now (now "%H:%M:%S"))) (<> @@ -21,6 +22,7 @@ :path "/geography/hypermedia/reference/api/greet" :method :post :csrf false + :returns "element" (&key) (let ((name (request-form "name" "stranger"))) (<> @@ -34,6 +36,7 @@ :path "/geography/hypermedia/reference/api/status" :method :put :csrf false + :returns "element" (&key) (let ((status (request-form "status" "unknown"))) (<> @@ -47,6 +50,7 @@ :path "/geography/hypermedia/reference/api/theme" :method :patch :csrf false + :returns "element" (&key) (let ((theme (request-form "theme" "unknown"))) (<> @@ -60,6 +64,7 @@ :path "/geography/hypermedia/reference/api/item/" :method :delete :csrf false + :returns "element" (&key) (<> (~doc-oob-code :target-id "ref-wire-sx-delete" :text "\"\""))) @@ -69,6 +74,7 @@ (defhandler ref-trigger-search :path "/geography/hypermedia/reference/api/trigger-search" :method :get + :returns "element" (&key) (let ((q (request-arg "q" ""))) (let ((sx-text (if (= q "") @@ -85,6 +91,7 @@ (defhandler ref-swap-item :path "/geography/hypermedia/reference/api/swap-item" :method :get + :returns "element" (&key) (let ((now (now "%H:%M:%S"))) (<> @@ -97,6 +104,7 @@ (defhandler ref-oob :path "/geography/hypermedia/reference/api/oob" :method :get + :returns "element" (&key) (let ((now (now "%H:%M:%S"))) (<> @@ -111,6 +119,7 @@ (defhandler ref-select-page :path "/geography/hypermedia/reference/api/select-page" :method :get + :returns "element" (&key) (let ((now (now "%H:%M:%S"))) (<> @@ -127,6 +136,7 @@ (defhandler ref-slow-echo :path "/geography/hypermedia/reference/api/slow-echo" :method :get + :returns "element" (&key) (let ((q (request-arg "q" ""))) (sleep 800) @@ -140,6 +150,7 @@ (defhandler ref-prompt-echo :path "/geography/hypermedia/reference/api/prompt-echo" :method :get + :returns "element" (&key) (let ((name (request-header "SX-Prompt" "anonymous"))) (<> @@ -152,5 +163,135 @@ (defhandler ref-error-500 :path "/geography/hypermedia/reference/api/error-500" :method :get + :returns "nil" (&key) (abort 500 "Server error")) + + +;; ========================================================================== +;; Remaining reference endpoints — migrated from Python +;; ========================================================================== + +;; --- sx-encoding demo: file upload name --- + +(defhandler ref-upload-name + :path "/geography/hypermedia/reference/api/upload-name" + :method :post + :csrf false + :returns "element" + (&key) + (let ((name (request-file-name "file"))) + (let ((display (if (nil? name) "(no file)" name))) + (let ((sx-text (str "(span :class \"text-stone-800 text-sm\" \"Received: \" (strong \"" display "\"))"))) + (<> + (span :class "text-stone-800 text-sm" "Received: " (strong display)) + (~doc-oob-code :target-id "ref-wire-sx-encoding" :text sx-text)))))) + +;; --- sx-headers demo: echo custom headers --- + +(defhandler ref-echo-headers + :path "/geography/hypermedia/reference/api/echo-headers" + :method :get + :returns "element" + (&key) + (let ((all-headers (into (list) (request-headers-all)))) + (let ((custom (filter + (fn (pair) (starts-with? (first pair) "x-")) + all-headers))) + (let ((sx-text + (if (empty? custom) + "(span :class \"text-stone-400 text-sm\" \"No custom headers received.\")" + (str "(ul :class \"text-sm text-stone-700 space-y-1\" " + (join " " (map (fn (pair) (str "(li (strong \"" (first pair) "\") \": \" \"" (nth pair 1) "\")")) custom)) + ")")))) + (<> + (if (empty? custom) + (span :class "text-stone-400 text-sm" "No custom headers received.") + (ul :class "text-sm text-stone-700 space-y-1" + (map (fn (pair) (li (strong (first pair)) ": " (nth pair 1))) custom))) + (~doc-oob-code :target-id "ref-wire-sx-headers" :text sx-text)))))) + +;; --- sx-include demo: echo GET query params --- + +(defhandler ref-echo-vals-get + :path "/geography/hypermedia/reference/api/echo-vals" + :method :get + :returns "element" + (&key) + (let ((vals (into (list) (request-args-all)))) + (let ((sx-text + (if (empty? vals) + "(span :class \"text-stone-400 text-sm\" \"No values received.\")" + (str "(ul :class \"text-sm text-stone-700 space-y-1\" " + (join " " (map (fn (pair) (str "(li (strong \"" (first pair) "\") \": \" \"" (nth pair 1) "\")")) vals)) + ")")))) + (<> + (if (empty? vals) + (span :class "text-stone-400 text-sm" "No values received.") + (ul :class "text-sm text-stone-700 space-y-1" + (map (fn (pair) (li (strong (first pair)) ": " (nth pair 1))) vals))) + (~doc-oob-code :target-id "ref-wire-sx-include" :text sx-text))))) + +;; --- sx-vals demo: echo POST form values --- + +(defhandler ref-echo-vals-post + :path "/geography/hypermedia/reference/api/echo-vals" + :method :post + :csrf false + :returns "element" + (&key) + (let ((vals (into (list) (request-form-all)))) + (let ((sx-text + (if (empty? vals) + "(span :class \"text-stone-400 text-sm\" \"No values received.\")" + (str "(ul :class \"text-sm text-stone-700 space-y-1\" " + (join " " (map (fn (pair) (str "(li (strong \"" (first pair) "\") \": \" \"" (nth pair 1) "\")")) vals)) + ")")))) + (<> + (if (empty? vals) + (span :class "text-stone-400 text-sm" "No values received.") + (ul :class "text-sm text-stone-700 space-y-1" + (map (fn (pair) (li (strong (first pair)) ": " (nth pair 1))) vals))) + (~doc-oob-code :target-id "ref-wire-sx-vals" :text sx-text))))) + +;; --- sx-retry demo: flaky endpoint (fails 2/3 times) --- + +(defhandler ref-flaky + :path "/geography/hypermedia/reference/api/flaky" + :method :get + :returns "element" + (&key) + (let ((n (+ (state-get "ref-flaky-n" 0) 1))) + (state-set! "ref-flaky-n" n) + (if (not (= (mod n 3) 0)) + (do + (set-response-status 503) + "") + (let ((sx-text (str "(span :class \"text-emerald-700 text-sm\" \"Success on attempt \" \"" n "\" \"!\")"))) + (<> + (span :class "text-emerald-700 text-sm" "Success on attempt " (str n) "!") + (~doc-oob-code :target-id "ref-wire-sx-retry" :text sx-text)))))) + +;; --- sx-trigger-event demo: response header triggers --- + +(defhandler ref-trigger-event + :path "/geography/hypermedia/reference/api/trigger-event" + :method :get + :returns "element" + (&key) + (let ((now (now "%H:%M:%S"))) + (set-response-header "SX-Trigger" "showNotice") + (<> + (span :class "text-stone-800 text-sm" "Loaded at " (strong now) " — check the border!")))) + +;; --- sx-retarget demo: response header retargets --- + +(defhandler ref-retarget + :path "/geography/hypermedia/reference/api/retarget" + :method :get + :returns "element" + (&key) + (let ((now (now "%H:%M:%S"))) + (set-response-header "SX-Retarget" "#ref-hdr-retarget-alt") + (<> + (span :class "text-violet-700 text-sm" "Retargeted at " (strong now)))))