diff --git a/src/mechcomp/web/app.py b/src/mechcomp/web/app.py index 38e0377..ff67c4e 100644 --- a/src/mechcomp/web/app.py +++ b/src/mechcomp/web/app.py @@ -21,9 +21,23 @@ WHAT IT SHOWS layer that does not exist yet. WHAT IT DOES NOT DO - It does not export STL, and it does not persist anything. It is a viewer and - a control panel over ``build()``. Rejections are shown as messages rather - than errors, because a rejection is the compiler doing its job. + It does not persist anything. Every design exists for the duration of one + request, and ``/m/stl`` rebuilds from the same query parameters rather than + caching what ``/api/build`` just made -- persistence is its own piece of + work and should not arrive here by accident. + + Rejections are shown as messages rather than errors, because a rejection is + the compiler doing its job. + +THE GATED PREFIX + Everything requiring membership lives under ``/m/``. See + ``docs/IDENTITY-CONTRACT.md`` section 5: the proxy gets one location block, + written once, so gating a new endpoint later is choosing a URL here rather + than changing shared infrastructure. + + ``/m/stl`` ships OPEN, because ``/m/`` is not yet gated and no membership + system exists to gate it. That is recorded in section 8 of that document, + not overlooked. """ from __future__ import annotations @@ -32,7 +46,7 @@ import json import os import traceback from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer -from typing import Any, Dict, List, Tuple +from typing import Any, Dict, List, NamedTuple, Tuple from urllib.parse import parse_qs, urlparse from mechcomp import svg @@ -65,6 +79,29 @@ ENUM_PARAMS: Dict[str, List[str]] = { "length_view": ["Preview", "Full Length"], } +# The longest member this service will export, in millimetres. +# +# WHY A CEILING AT ALL +# ``/m/stl`` is reachable without authentication on a public name, and +# ``member_length_ft`` is an unbounded number. The response body grows with +# nothing but that parameter, so a request for a ten-thousand-foot member +# is a large computation and a large download asked for by one short URL. +# ``/api/build`` has the same exposure and a constant-size answer; export +# is where it becomes worth bounding. +# +# WHY 3048, AND WHAT IT IS NOT SAYING +# One ten-foot member -- the bottom of the range ROADMAP section 2 gives +# for members. It is NOT a claim that longer members are wrong. A hundred +# foot member is a real artifact and nobody prints one in a piece; it gets +# sectioned. The export ceiling and the member catalogue answer different +# questions and conflating them would be the mistake. +# +# WHY CONFIGURATION RATHER THAN A PARAMETER +# A limit the caller can raise is not a limit. The deployment can lift this +# through MECHCOMP_MAX_EXPORT_MM in mechcomp.env without a code change; a +# query parameter could not be trusted to. +DEFAULT_MAX_EXPORT_MM = 3048.0 + # Parameters that apply to every profile, in the order they make sense to a # person: what the stock is, how it fits, then how thick the printed walls are. # @@ -129,12 +166,19 @@ def profile_params(family, profile: str) -> List[Tuple[str, List[str]]]: return groups -def build_payload(family_name: str, profile: str, - overrides: Dict[str, Any], - author: str = "") -> Dict[str, Any]: - from mechcomp.profiles import ProfileRejected, build +def typed_overrides(family, overrides: Dict[str, Any]) -> Dict[str, Any]: + """ + Coerce raw query strings to the types the family's defaults declare. - family = families()[family_name] + Shared by the view and the export deliberately. If the two coerced + differently, the downloaded file would not be the part on screen -- and + the difference would be invisible, because both would look correct on + their own. + + An unparseable value is dropped rather than rejected, leaving the family + default: the composer is a viewer, and a half-typed number in a text box + should not blank the drawing. + """ typed: Dict[str, Any] = {} for key, raw in overrides.items(): if key not in family.defaults: @@ -161,6 +205,16 @@ def build_payload(family_name: str, profile: str, typed[key] = raw except (TypeError, ValueError): continue + return typed + + +def build_payload(family_name: str, profile: str, + overrides: Dict[str, Any], + author: str = "") -> Dict[str, Any]: + from mechcomp.profiles import ProfileRejected, build + + family = families()[family_name] + typed = typed_overrides(family, overrides) payload: Dict[str, Any] = { "family": family_name, @@ -192,6 +246,74 @@ def build_payload(family_name: str, profile: str, return payload +class ExportRefusal(NamedTuple): + """A refusal the caller should see as text, not as a stack trace.""" + status: int + message: str + + +def build_export(family_name: str, profile: str, + overrides: Dict[str, Any], + author: str = "", + ceiling: float = None) -> Any: + """ + Build a member and return ``(filename, bytes)``, or an ``ExportRefusal``. + + Rebuilt from the query parameters rather than taken from anything + ``/api/build`` cached, so the file is a function of the URL and the + composer keeps holding no state. + + A rejection is not an error. ``ProfileRejected`` and ``ExportRefused`` are + both the compiler declining to vouch for something, and both should reach + the caller as readable text with a 4xx. Only an unexpected exception is a + 500, because only that is the compiler failing rather than working. + """ + from mechcomp import stl + from mechcomp.profiles import ProfileRejected, build + + if ceiling is None: + ceiling = max_export_mm() + + family = families().get(family_name) + if family is None: + return ExportRefusal(404, "Unknown family: %s" % family_name) + + typed = typed_overrides(family, overrides) + + try: + result = build(family=family_name, profile=profile, params=typed, + author=author) + except ProfileRejected as exc: + return ExportRefusal(422, str(exc)) + except KeyError: + return ExportRefusal(404, "Unknown profile: %s" % profile) + except Exception as exc: # noqa: BLE001 + return ExportRefusal(500, "%s: %s" % (type(exc).__name__, exc)) + + length = result.report["LENGTH_MM"] + if length > ceiling: + # Named rather than truncated. Silently exporting a shorter member than + # was asked for would produce a file whose design record describes + # something else -- the exact failure the length_view control was added + # to prevent, arriving by a different door. + return ExportRefusal( + 413, + "This member is %g mm long and the export limit is %g mm. " + "Nothing is truncated: a shorter file would carry a design record " + "describing a different object. Raise MECHCOMP_MAX_EXPORT_MM in " + "the deployment's environment file if a longer export is wanted." + % (length, ceiling)) + + try: + data = stl.member_stl(result) + except stl.ExportRefused as exc: + return ExportRefusal(422, str(exc)) + except Exception as exc: # noqa: BLE001 + return ExportRefusal(500, "%s: %s" % (type(exc).__name__, exc)) + + return (stl.stl_filename(result), data) + + PAGE = """ @@ -235,6 +357,12 @@ PAGE = """ .toggles { margin-bottom:12px; font-size:12.5px; } .toggles label { display:inline-flex; grid-template-columns:none; gap:5px; margin-right:14px; } .toggles input { width:auto; } + .export { margin-top:14px; display:flex; align-items:center; gap:10px; } + .export button { font:inherit; font-size:12.5px; padding:6px 13px; cursor:pointer; + border:1px solid var(--accent); border-radius:5px; + background:var(--accent); color:#fff; } + .export button:disabled { background:#c3ccd4; border-color:#c3ccd4; cursor:default; } + #export-note { font-size:12px; color:#7b8794; }

Mechanical Compiler

@@ -263,6 +391,10 @@ PAGE = """
+
+ + +
Report
Design record
@@ -272,6 +404,11 @@ PAGE = """