jmfts-web 0.6.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- jmfts_web-0.6.0/LICENSE +21 -0
- jmfts_web-0.6.0/PKG-INFO +88 -0
- jmfts_web-0.6.0/README.md +64 -0
- jmfts_web-0.6.0/jmfts_web/__init__.py +73 -0
- jmfts_web-0.6.0/jmfts_web/static/client/copyout.d.ts +23 -0
- jmfts_web-0.6.0/jmfts_web/static/client/copyout.js +226 -0
- jmfts_web-0.6.0/jmfts_web/static/client/index.d.ts +18 -0
- jmfts_web-0.6.0/jmfts_web/static/client/index.js +26 -0
- jmfts_web-0.6.0/jmfts_web/static/client/operations.d.ts +159 -0
- jmfts_web-0.6.0/jmfts_web/static/client/operations.js +3004 -0
- jmfts_web-0.6.0/jmfts_web/static/client/transport.d.ts +72 -0
- jmfts_web-0.6.0/jmfts_web/static/client/transport.js +365 -0
- jmfts_web-0.6.0/jmfts_web/static/client/verbs.d.ts +4304 -0
- jmfts_web-0.6.0/jmfts_web/static/client/verbs.js +1122 -0
- jmfts_web-0.6.0/jmfts_web/static/index.html +38 -0
- jmfts_web-0.6.0/jmfts_web/static/render/markdown.js +481 -0
- jmfts_web-0.6.0/jmfts_web/static/render/overlay.js +384 -0
- jmfts_web-0.6.0/jmfts_web/static/render/renderers.js +292 -0
- jmfts_web-0.6.0/jmfts_web/static/render/sheet.js +276 -0
- jmfts_web-0.6.0/jmfts_web/static/render/surfaces.js +573 -0
- jmfts_web-0.6.0/jmfts_web/static/render/transcript.js +123 -0
- jmfts_web-0.6.0/jmfts_web/static/shell/citation.js +191 -0
- jmfts_web-0.6.0/jmfts_web/static/shell/manifest.js +32 -0
- jmfts_web-0.6.0/jmfts_web/static/shell/registry.js +171 -0
- jmfts_web-0.6.0/jmfts_web/static/shell/shell.css +200 -0
- jmfts_web-0.6.0/jmfts_web/static/shell/shell.js +336 -0
- jmfts_web-0.6.0/jmfts_web/static/views/capabilities.js +300 -0
- jmfts_web-0.6.0/jmfts_web/static/views/document.css +87 -0
- jmfts_web-0.6.0/jmfts_web/static/views/document.js +1158 -0
- jmfts_web-0.6.0/jmfts_web/static/views/log.css +86 -0
- jmfts_web-0.6.0/jmfts_web/static/views/log.js +331 -0
- jmfts_web-0.6.0/jmfts_web/static/views/presets.css +68 -0
- jmfts_web-0.6.0/jmfts_web/static/views/presets.js +1127 -0
- jmfts_web-0.6.0/jmfts_web/static/views/search.css +77 -0
- jmfts_web-0.6.0/jmfts_web/static/views/search.js +1461 -0
- jmfts_web-0.6.0/jmfts_web/static/views/tree.css +55 -0
- jmfts_web-0.6.0/jmfts_web/static/views/tree.js +351 -0
- jmfts_web-0.6.0/jmfts_web/static/views/upload.css +202 -0
- jmfts_web-0.6.0/jmfts_web/static/views/upload.js +1293 -0
- jmfts_web-0.6.0/jmfts_web.egg-info/PKG-INFO +88 -0
- jmfts_web-0.6.0/jmfts_web.egg-info/SOURCES.txt +43 -0
- jmfts_web-0.6.0/jmfts_web.egg-info/dependency_links.txt +1 -0
- jmfts_web-0.6.0/jmfts_web.egg-info/top_level.txt +1 -0
- jmfts_web-0.6.0/pyproject.toml +71 -0
- jmfts_web-0.6.0/setup.cfg +4 -0
jmfts_web-0.6.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Fight Fire with Fire Robotics, LLC
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
jmfts_web-0.6.0/PKG-INFO
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: jmfts-web
|
|
3
|
+
Version: 0.6.0
|
|
4
|
+
Summary: Browser front end for a JMFTS retrieval appliance
|
|
5
|
+
Author: Fight Fire with Fire Robotics, LLC
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/jmccardle/jmfts
|
|
8
|
+
Project-URL: Repository, https://github.com/jmccardle/jmfts
|
|
9
|
+
Keywords: jmfts,retrieval,ui,frontend
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Environment :: Web Environment
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
19
|
+
Classifier: Topic :: Internet :: WWW/HTTP :: Dynamic Content
|
|
20
|
+
Requires-Python: >=3.11
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
License-File: LICENSE
|
|
23
|
+
Dynamic: license-file
|
|
24
|
+
|
|
25
|
+
# jmfts-web
|
|
26
|
+
|
|
27
|
+
The browser front end for a [JMFTS](https://github.com/jmccardle/jmfts) retrieval appliance.
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
pip install 'jmfts[web]'
|
|
31
|
+
uvicorn jmfts_core.rest.main:app --host 0.0.0.0 --port 8100
|
|
32
|
+
# then open http://localhost:8100/app/
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
This wheel carries built static files and one module that says where they are. It has **no
|
|
36
|
+
dependencies**: everything the front end does it does in the browser, over the same REST API
|
|
37
|
+
any other client calls. Installing it adds no Python package to the appliance.
|
|
38
|
+
|
|
39
|
+
It is not useful on its own — there is nothing to import and nothing to run. `jmfts[web]`
|
|
40
|
+
depends on it, and `jmfts_core.rest.main` mounts it at `/app` when it is present. An
|
|
41
|
+
appliance without it starts normally and serves no `/app`.
|
|
42
|
+
|
|
43
|
+
## One generated client, and one event per call
|
|
44
|
+
|
|
45
|
+
`jmfts_web/static/client/` is the only way this front end talks to an appliance. Four of its
|
|
46
|
+
files are generated from the appliance's own `/openapi.json`, the way `jmfts-client`'s
|
|
47
|
+
`_verbs.py` is generated from the route table:
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
python -m scripts.generate_ts_client # in a jmfts source checkout
|
|
51
|
+
python -m scripts.generate_ts_client --check # exit 1 if the bundle is stale
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
The other six are the hand-written runtime — the request path, the three copy-out printers,
|
|
55
|
+
and the entry point. `tests/test_ts_client_codegen.py` refuses a stale generated file and
|
|
56
|
+
refuses a file that is in neither set.
|
|
57
|
+
|
|
58
|
+
Every call emits one event: `{op_id, method, path, path_params, query, body, status,
|
|
59
|
+
response, ms}`. Three things follow from that.
|
|
60
|
+
|
|
61
|
+
* **Replay.** An event is a call plus its arguments, so `client.replay(event, {…})` re-sends
|
|
62
|
+
it with any of them edited.
|
|
63
|
+
* **Verifiable thinness.** A view showing something the call log has no event for computed it
|
|
64
|
+
in the browser. That is readable from the log rather than arguable from the source.
|
|
65
|
+
* **Copy-out.** `asCurl`, `asPython` and `asFetch` print any event three ways — and none of
|
|
66
|
+
them prints the credential. Each names the environment variable it lives in instead.
|
|
67
|
+
|
|
68
|
+
There is no build step and no bundler: these are ES modules the browser runs as they stand,
|
|
69
|
+
with `.d.ts` declarations beside them for TypeScript consumers. Nothing loads from a CDN, a
|
|
70
|
+
font host or anywhere but the appliance's own origin, because this appliance is expected to
|
|
71
|
+
run air-gapped.
|
|
72
|
+
|
|
73
|
+
## Why a separate distribution
|
|
74
|
+
|
|
75
|
+
An extra guards dependencies. Static files inside the server package would ship with the
|
|
76
|
+
appliance whether or not an extra named them, so `jmfts[web]` over a bundle in the main wheel
|
|
77
|
+
would have been a flag that guards nothing. `Dockerfile.worker` builds a worker that drains
|
|
78
|
+
the ingest queue and never serves a page.
|
|
79
|
+
|
|
80
|
+
## Versioning
|
|
81
|
+
|
|
82
|
+
One number, three wheels, one tag — `jmfts`, `jmfts-client` and `jmfts-web` release in
|
|
83
|
+
lockstep. The front end is written against the appliance's own route table, so a UI one
|
|
84
|
+
release behind its server renders controls for operations that have moved.
|
|
85
|
+
|
|
86
|
+
## Licence
|
|
87
|
+
|
|
88
|
+
MIT. See `LICENSE`.
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# jmfts-web
|
|
2
|
+
|
|
3
|
+
The browser front end for a [JMFTS](https://github.com/jmccardle/jmfts) retrieval appliance.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
pip install 'jmfts[web]'
|
|
7
|
+
uvicorn jmfts_core.rest.main:app --host 0.0.0.0 --port 8100
|
|
8
|
+
# then open http://localhost:8100/app/
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
This wheel carries built static files and one module that says where they are. It has **no
|
|
12
|
+
dependencies**: everything the front end does it does in the browser, over the same REST API
|
|
13
|
+
any other client calls. Installing it adds no Python package to the appliance.
|
|
14
|
+
|
|
15
|
+
It is not useful on its own — there is nothing to import and nothing to run. `jmfts[web]`
|
|
16
|
+
depends on it, and `jmfts_core.rest.main` mounts it at `/app` when it is present. An
|
|
17
|
+
appliance without it starts normally and serves no `/app`.
|
|
18
|
+
|
|
19
|
+
## One generated client, and one event per call
|
|
20
|
+
|
|
21
|
+
`jmfts_web/static/client/` is the only way this front end talks to an appliance. Four of its
|
|
22
|
+
files are generated from the appliance's own `/openapi.json`, the way `jmfts-client`'s
|
|
23
|
+
`_verbs.py` is generated from the route table:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
python -m scripts.generate_ts_client # in a jmfts source checkout
|
|
27
|
+
python -m scripts.generate_ts_client --check # exit 1 if the bundle is stale
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
The other six are the hand-written runtime — the request path, the three copy-out printers,
|
|
31
|
+
and the entry point. `tests/test_ts_client_codegen.py` refuses a stale generated file and
|
|
32
|
+
refuses a file that is in neither set.
|
|
33
|
+
|
|
34
|
+
Every call emits one event: `{op_id, method, path, path_params, query, body, status,
|
|
35
|
+
response, ms}`. Three things follow from that.
|
|
36
|
+
|
|
37
|
+
* **Replay.** An event is a call plus its arguments, so `client.replay(event, {…})` re-sends
|
|
38
|
+
it with any of them edited.
|
|
39
|
+
* **Verifiable thinness.** A view showing something the call log has no event for computed it
|
|
40
|
+
in the browser. That is readable from the log rather than arguable from the source.
|
|
41
|
+
* **Copy-out.** `asCurl`, `asPython` and `asFetch` print any event three ways — and none of
|
|
42
|
+
them prints the credential. Each names the environment variable it lives in instead.
|
|
43
|
+
|
|
44
|
+
There is no build step and no bundler: these are ES modules the browser runs as they stand,
|
|
45
|
+
with `.d.ts` declarations beside them for TypeScript consumers. Nothing loads from a CDN, a
|
|
46
|
+
font host or anywhere but the appliance's own origin, because this appliance is expected to
|
|
47
|
+
run air-gapped.
|
|
48
|
+
|
|
49
|
+
## Why a separate distribution
|
|
50
|
+
|
|
51
|
+
An extra guards dependencies. Static files inside the server package would ship with the
|
|
52
|
+
appliance whether or not an extra named them, so `jmfts[web]` over a bundle in the main wheel
|
|
53
|
+
would have been a flag that guards nothing. `Dockerfile.worker` builds a worker that drains
|
|
54
|
+
the ingest queue and never serves a page.
|
|
55
|
+
|
|
56
|
+
## Versioning
|
|
57
|
+
|
|
58
|
+
One number, three wheels, one tag — `jmfts`, `jmfts-client` and `jmfts-web` release in
|
|
59
|
+
lockstep. The front end is written against the appliance's own route table, so a UI one
|
|
60
|
+
release behind its server renders controls for operations that have moved.
|
|
61
|
+
|
|
62
|
+
## Licence
|
|
63
|
+
|
|
64
|
+
MIT. See `LICENSE`.
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""jmfts-web — the browser front end for a JMFTS appliance, as its own distribution.
|
|
2
|
+
|
|
3
|
+
IC-5, ``docs/SPRINT_0_6_0.md`` Block F step 19. This package holds one thing: a directory of
|
|
4
|
+
built static files, and the function that says where it is. There is no Python logic here and
|
|
5
|
+
there is not meant to be — the appliance mounts :func:`static_dir` and serves it, and
|
|
6
|
+
everything the front end does it does by calling the same REST API any other client calls.
|
|
7
|
+
|
|
8
|
+
**Why this is a third distribution rather than a directory inside ``jmfts_core``.** An extra
|
|
9
|
+
guards dependencies. Static files inside the server package ship with the appliance whether or
|
|
10
|
+
not an extra names them, so ``jmfts[web]`` over a bundle in the main wheel would have been a
|
|
11
|
+
flag that guards nothing. ``Dockerfile.worker`` builds a worker that drains the ingest queue
|
|
12
|
+
and never serves a page; it should not carry a UI it cannot use. Question 4.8 of that sprint
|
|
13
|
+
plan records the cost that was accepted for this: a third version in ``bump-version.sh``, a
|
|
14
|
+
step in ``docs/RELEASING.md``, and a third case in ``.github/workflows/publish.yml``.
|
|
15
|
+
|
|
16
|
+
**The version tracks the other two exactly.** One number, three wheels, one tag. The front end
|
|
17
|
+
is generated against the appliance's own route table, so a client one release behind its server
|
|
18
|
+
is a client that renders controls for operations that have moved.
|
|
19
|
+
|
|
20
|
+
``jmfts_core.rest.main`` imports this package inside a ``try``: a base install has no
|
|
21
|
+
``jmfts_web`` and must start anyway, with no ``/app`` route rather than a broken one.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
|
|
28
|
+
__version__ = "0.6.0"
|
|
29
|
+
|
|
30
|
+
__all__ = ["static_dir", "index_path", "__version__"]
|
|
31
|
+
|
|
32
|
+
#: The bundle, relative to this file. ``tests/test_web_distribution.py`` refuses a
|
|
33
|
+
#: distribution whose bundle is missing.
|
|
34
|
+
#:
|
|
35
|
+
#: **There is no build step and that is not an omission.** Part of ``static/client/`` IS
|
|
36
|
+
#: generated — ``scripts.generate_ts_client`` renders it from the appliance's
|
|
37
|
+
#: ``/openapi.json`` (Block F step 23) — but it is generated as ES modules the browser runs
|
|
38
|
+
#: as they stand, so nothing has to be compiled between here and a page. What a build step
|
|
39
|
+
#: would add is a bundler and a TypeScript compiler, and Block F step 24 is where that
|
|
40
|
+
#: decision belongs; until there is a source tree to compile, it would be a file that runs
|
|
41
|
+
#: and does nothing.
|
|
42
|
+
_STATIC = Path(__file__).resolve().parent / "static"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def static_dir() -> Path:
|
|
46
|
+
"""The directory to mount, as an absolute path.
|
|
47
|
+
|
|
48
|
+
Raises ``FileNotFoundError`` when the bundle is absent rather than returning a path that
|
|
49
|
+
does not exist. A ``StaticFiles`` mount over a missing directory raises at mount time in
|
|
50
|
+
some Starlette versions and answers 404 for every asset in others; neither is a state the
|
|
51
|
+
appliance should boot into quietly, because both look exactly like "the front end is
|
|
52
|
+
broken" to whoever opens it.
|
|
53
|
+
"""
|
|
54
|
+
if not _STATIC.is_dir():
|
|
55
|
+
raise FileNotFoundError(
|
|
56
|
+
f"jmfts-web is installed but its bundle is missing at {_STATIC}. "
|
|
57
|
+
"This is a packaging fault, not a configuration one: reinstall the wheel, or in "
|
|
58
|
+
"a source checkout run python -m scripts.generate_ts_client."
|
|
59
|
+
)
|
|
60
|
+
return _STATIC
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def index_path() -> Path:
|
|
64
|
+
"""The single-page entry document.
|
|
65
|
+
|
|
66
|
+
Named separately from :func:`static_dir` because the mount serves assets by path and the
|
|
67
|
+
application needs one document served for every unmatched route — a front end with client
|
|
68
|
+
-side routing answers ``/app/search`` from the same file as ``/app/``.
|
|
69
|
+
"""
|
|
70
|
+
index = static_dir() / "index.html"
|
|
71
|
+
if not index.is_file():
|
|
72
|
+
raise FileNotFoundError(f"jmfts-web's bundle has no index.html at {index}.")
|
|
73
|
+
return index
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Types for the three copy-out renderers. Hand-written; `copyout.js` is the code.
|
|
2
|
+
|
|
3
|
+
import type { CallEvent } from "./transport.js";
|
|
4
|
+
|
|
5
|
+
export interface CopyOutOptions {
|
|
6
|
+
/**
|
|
7
|
+
* The appliance's origin to print. Empty keeps the URL relative, which is right for
|
|
8
|
+
* `fetch` from the same page and wrong for a `curl` somebody will paste into a terminal.
|
|
9
|
+
*/
|
|
10
|
+
baseUrl?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** The event as a `curl` invocation, with the credential named and never printed. */
|
|
14
|
+
export declare function asCurl(event: CallEvent, options?: CopyOutOptions): string;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The event as a `jmfts_client.RemoteJmftsClient` call, or as the `httpx` call that is its
|
|
18
|
+
* honest equivalent for a route with no generated verb.
|
|
19
|
+
*/
|
|
20
|
+
export declare function asPython(event: CallEvent, options?: CopyOutOptions): string;
|
|
21
|
+
|
|
22
|
+
/** The event as the browser `fetch` this client itself issued. */
|
|
23
|
+
export declare function asFetch(event: CallEvent, options?: CopyOutOptions): string;
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
// @ts-self-types="./copyout.d.ts"
|
|
2
|
+
//
|
|
3
|
+
// A call event, printed three ways. Hand-written.
|
|
4
|
+
//
|
|
5
|
+
// docs/SPRINT_0_6_0.md Block F step 23, third property: "an operation id maps to a
|
|
6
|
+
// `_verbs.py` method name, so an event prints as curl with the token elided, as a
|
|
7
|
+
// RemoteJmftsClient call, and as fetch. Worked examples stop being a thing anybody writes
|
|
8
|
+
// by hand."
|
|
9
|
+
//
|
|
10
|
+
// That is the point. Every example in this appliance's documentation of how to call it is a
|
|
11
|
+
// thing that can go stale; a printed event cannot, because it is a transcript of a request
|
|
12
|
+
// that was actually made a moment ago, rendered from the same table the request was built
|
|
13
|
+
// from.
|
|
14
|
+
//
|
|
15
|
+
// THE CREDENTIAL IS NEVER PRINTED. Each renderer names the environment variable or the
|
|
16
|
+
// variable the reader is expected to supply. A copy button that put the appliance's token on
|
|
17
|
+
// the clipboard, and from there into a chat window or a bug report, would be a credential
|
|
18
|
+
// leak with a convenience feature around it.
|
|
19
|
+
|
|
20
|
+
import { OPERATIONS } from "./operations.js";
|
|
21
|
+
import { queryString } from "./transport.js";
|
|
22
|
+
|
|
23
|
+
/** The shell/environment variable each credential is named by, never its value. */
|
|
24
|
+
const CREDENTIAL_ENV = Object.freeze({
|
|
25
|
+
JMFTSToken: "JMFTS_API_TOKEN",
|
|
26
|
+
JMFTSRunnerKey: "JMFTS_RUNNER_KEY",
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
function operationFor(event) {
|
|
30
|
+
const op = OPERATIONS[event.op_id];
|
|
31
|
+
if (!op) throw new Error(`no such operation: ${event.op_id}`);
|
|
32
|
+
return op;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** `'...'` for POSIX sh, with embedded single quotes closed and reopened. */
|
|
36
|
+
function shellQuote(text) {
|
|
37
|
+
return `'${String(text).replace(/'/g, `'\\''`)}'`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Continue a multi-line literal from the column it was written at. */
|
|
41
|
+
function reindent(text, spaces) {
|
|
42
|
+
const pad = " ".repeat(spaces);
|
|
43
|
+
return text.split("\n").join(`\n${pad}`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** What a file part is, without its bytes: curl and fetch both need the name, not the data. */
|
|
47
|
+
function partName(value) {
|
|
48
|
+
if (value && typeof value === "object" && typeof value.name === "string") return value.name;
|
|
49
|
+
return "FILE";
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The event as a `curl` invocation.
|
|
54
|
+
*
|
|
55
|
+
* @param {object} event a call event
|
|
56
|
+
* @param {{baseUrl?: string}} [options] the appliance's origin; "" keeps the path relative
|
|
57
|
+
*/
|
|
58
|
+
export function asCurl(event, options = {}) {
|
|
59
|
+
const op = operationFor(event);
|
|
60
|
+
const base = (options.baseUrl ?? "").replace(/\/$/, "");
|
|
61
|
+
const url = base + event.path + queryString(event.query);
|
|
62
|
+
const lines = [`curl -sS -X ${op.method}`];
|
|
63
|
+
|
|
64
|
+
if (op.security_scheme) {
|
|
65
|
+
// Double quotes so the shell expands the variable; the value itself is never here.
|
|
66
|
+
lines.push(` -H "Authorization: Bearer $${CREDENTIAL_ENV[op.security_scheme]}"`);
|
|
67
|
+
}
|
|
68
|
+
if (op.body_media_type === "multipart/form-data") {
|
|
69
|
+
for (const name of op.file_params) {
|
|
70
|
+
if (event.body?.[name] !== undefined) {
|
|
71
|
+
lines.push(` -F ${shellQuote(`${name}=@${partName(event.body[name])}`)}`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
for (const name of op.form_params) {
|
|
75
|
+
if (event.body?.[name] !== undefined && event.body[name] !== null) {
|
|
76
|
+
lines.push(` -F ${shellQuote(`${name}=${JSON.stringify(event.body[name])}`)}`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
} else if (op.body_param && event.body !== undefined) {
|
|
80
|
+
if (op.body_media_type === "application/json") {
|
|
81
|
+
lines.push(` -H 'Content-Type: application/json'`);
|
|
82
|
+
lines.push(` -d ${shellQuote(JSON.stringify(event.body))}`);
|
|
83
|
+
} else {
|
|
84
|
+
lines.push(` -H ${shellQuote(`Content-Type: ${op.body_media_type}; charset=utf-8`)}`);
|
|
85
|
+
lines.push(` -d ${shellQuote(event.body)}`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
if (op.binary) {
|
|
89
|
+
lines.push(` -o out.${(op.response_media_type || "bin").split("/").pop()}`);
|
|
90
|
+
}
|
|
91
|
+
lines.push(` ${shellQuote(url)}`);
|
|
92
|
+
return lines.join(" \\\n");
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** A JSON value as Python source. */
|
|
96
|
+
function asPythonLiteral(value, indent = "") {
|
|
97
|
+
if (value === null || value === undefined) return "None";
|
|
98
|
+
if (value === true) return "True";
|
|
99
|
+
if (value === false) return "False";
|
|
100
|
+
if (typeof value === "number") return String(value);
|
|
101
|
+
if (typeof value === "string") return JSON.stringify(value);
|
|
102
|
+
if (Array.isArray(value)) {
|
|
103
|
+
if (value.length === 0) return "[]";
|
|
104
|
+
const inner = value.map((v) => `${indent} ${asPythonLiteral(v, `${indent} `)}`);
|
|
105
|
+
return `[\n${inner.join(",\n")},\n${indent}]`;
|
|
106
|
+
}
|
|
107
|
+
if (value instanceof Blob) return `open(${JSON.stringify(partName(value))}, "rb").read()`;
|
|
108
|
+
const entries = Object.entries(value);
|
|
109
|
+
if (entries.length === 0) return "{}";
|
|
110
|
+
const inner = entries.map(
|
|
111
|
+
([k, v]) => `${indent} ${JSON.stringify(k)}: ${asPythonLiteral(v, `${indent} `)}`
|
|
112
|
+
);
|
|
113
|
+
return `{\n${inner.join(",\n")},\n${indent}}`;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The event as a `jmfts_client.RemoteJmftsClient` call.
|
|
118
|
+
*
|
|
119
|
+
* Seven of the appliance's routes are hand-written FastAPI routes rather than `@expose`'d
|
|
120
|
+
* service methods, so they have no generated verb. For those this prints the `httpx` call
|
|
121
|
+
* that is the honest equivalent, rather than a client method that does not exist.
|
|
122
|
+
*/
|
|
123
|
+
export function asPython(event, options = {}) {
|
|
124
|
+
const op = operationFor(event);
|
|
125
|
+
const base = options.baseUrl || "http://localhost:8100";
|
|
126
|
+
const env = op.security_scheme ? CREDENTIAL_ENV[op.security_scheme] : null;
|
|
127
|
+
|
|
128
|
+
if (!op.python_verb) {
|
|
129
|
+
const header = env ? `, headers={"Authorization": f"Bearer {os.environ['${env}']}"}` : "";
|
|
130
|
+
const body =
|
|
131
|
+
op.body_param && event.body !== undefined ? `, json=${asPythonLiteral(event.body)}` : "";
|
|
132
|
+
const url = `${base}${event.path}${queryString(event.query)}`;
|
|
133
|
+
return [
|
|
134
|
+
...(env ? ["import os", ""] : []),
|
|
135
|
+
"import httpx",
|
|
136
|
+
"",
|
|
137
|
+
`# ${op.op_id} is a hand-written route: jmfts-client has no verb for it.`,
|
|
138
|
+
`httpx.request(${JSON.stringify(op.method)}, ${JSON.stringify(url)}${header}${body})`,
|
|
139
|
+
].join("\n");
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Arguments are printed one per line at eight spaces, so nested literals continue from
|
|
143
|
+
// there: a pasted call has to be valid Python, not merely recognisable as one.
|
|
144
|
+
const inner = " ";
|
|
145
|
+
const args = [];
|
|
146
|
+
for (const name of op.path_params) {
|
|
147
|
+
args.push(`${name}=${asPythonLiteral(event.path_params[name], inner)}`);
|
|
148
|
+
}
|
|
149
|
+
if (op.body_param && event.body !== undefined) {
|
|
150
|
+
args.push(`${op.body_param}=${asPythonLiteral(event.body, inner)}`);
|
|
151
|
+
}
|
|
152
|
+
for (const name of [...op.file_params, ...op.form_params]) {
|
|
153
|
+
if (event.body?.[name] !== undefined) {
|
|
154
|
+
args.push(`${name}=${asPythonLiteral(event.body[name], inner)}`);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
for (const [name, value] of Object.entries(event.query)) {
|
|
158
|
+
args.push(`${name}=${asPythonLiteral(value, inner)}`);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const token = env ? `, token=os.environ[${JSON.stringify(env)}]` : "";
|
|
162
|
+
const imports = env ? ["import os", ""] : [];
|
|
163
|
+
return [
|
|
164
|
+
...imports,
|
|
165
|
+
"from jmfts_client import RemoteJmftsClient",
|
|
166
|
+
"",
|
|
167
|
+
`with RemoteJmftsClient(${JSON.stringify(base)}${token}) as client:`,
|
|
168
|
+
` result = client.${op.python_verb}(`,
|
|
169
|
+
...args.map((arg) => ` ${arg},`),
|
|
170
|
+
" )",
|
|
171
|
+
].join("\n");
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** The event as a browser `fetch`, which is what this client itself did. */
|
|
175
|
+
export function asFetch(event, options = {}) {
|
|
176
|
+
const op = operationFor(event);
|
|
177
|
+
const base = (options.baseUrl ?? "").replace(/\/$/, "");
|
|
178
|
+
const url = base + event.path + queryString(event.query);
|
|
179
|
+
const headers = [];
|
|
180
|
+
if (op.security_scheme) headers.push(` "Authorization": \`Bearer \${token}\`,`);
|
|
181
|
+
|
|
182
|
+
const init = [` method: ${JSON.stringify(op.method)},`];
|
|
183
|
+
let prelude = [];
|
|
184
|
+
if (op.body_media_type === "multipart/form-data") {
|
|
185
|
+
prelude = ["const form = new FormData();"];
|
|
186
|
+
for (const name of op.file_params) {
|
|
187
|
+
if (event.body?.[name] !== undefined) {
|
|
188
|
+
// The bytes are not printable, so this names where they come from and says which
|
|
189
|
+
// file was actually sent. Nothing here pretends to reproduce the upload.
|
|
190
|
+
prelude.push(
|
|
191
|
+
`form.append(${JSON.stringify(name)}, fileInput.files[0]); ` +
|
|
192
|
+
`// was: ${partName(event.body[name])}`
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
for (const name of op.form_params) {
|
|
197
|
+
if (event.body?.[name] !== undefined && event.body[name] !== null) {
|
|
198
|
+
prelude.push(
|
|
199
|
+
`form.append(${JSON.stringify(name)}, ${JSON.stringify(
|
|
200
|
+
JSON.stringify(event.body[name])
|
|
201
|
+
)});`
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
init.push(" body: form,");
|
|
206
|
+
} else if (op.body_param && event.body !== undefined) {
|
|
207
|
+
if (op.body_media_type === "application/json") {
|
|
208
|
+
headers.push(` "Content-Type": "application/json",`);
|
|
209
|
+
init.push(` body: JSON.stringify(${reindent(JSON.stringify(event.body, null, 2), 4)}),`);
|
|
210
|
+
} else {
|
|
211
|
+
const declared = JSON.stringify(`${op.body_media_type}; charset=utf-8`);
|
|
212
|
+
headers.push(` "Content-Type": ${declared},`);
|
|
213
|
+
init.push(` body: ${JSON.stringify(event.body)},`);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
if (headers.length) init.splice(1, 0, " headers: {", ...headers, " },");
|
|
217
|
+
|
|
218
|
+
const read = op.binary ? "await response.blob()" : "await response.json()";
|
|
219
|
+
return [
|
|
220
|
+
...prelude,
|
|
221
|
+
`const response = await fetch(${JSON.stringify(url)}, {`,
|
|
222
|
+
...init,
|
|
223
|
+
"});",
|
|
224
|
+
`const result = ${read};`,
|
|
225
|
+
].join("\n");
|
|
226
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// Types for the entry point. Hand-written; `index.js` is the code.
|
|
2
|
+
|
|
3
|
+
export { JmftsClient } from "./verbs.js";
|
|
4
|
+
export { OPERATIONS, OP_IDS } from "./operations.js";
|
|
5
|
+
export type { Operation, OpId } from "./operations.js";
|
|
6
|
+
export {
|
|
7
|
+
CALL_EVENT_FIELDS,
|
|
8
|
+
CREDENTIAL_KEYS,
|
|
9
|
+
TOKEN_KEY,
|
|
10
|
+
JmftsHttpError,
|
|
11
|
+
JmftsTransport,
|
|
12
|
+
JmftsTransportError,
|
|
13
|
+
credentialStorage,
|
|
14
|
+
detailOf,
|
|
15
|
+
} from "./transport.js";
|
|
16
|
+
export type { CallEvent, JmftsTransportOptions } from "./transport.js";
|
|
17
|
+
export { asCurl, asFetch, asPython } from "./copyout.js";
|
|
18
|
+
export type { CopyOutOptions } from "./copyout.js";
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// @ts-self-types="./index.d.ts"
|
|
2
|
+
//
|
|
3
|
+
// The one import a view needs. Hand-written.
|
|
4
|
+
//
|
|
5
|
+
// docs/SPRINT_0_6_0.md Block F step 23. Every view calls ONE client, and this is the door to
|
|
6
|
+
// it — `import { JmftsClient } from "./client/index.js"`. Nothing here has behaviour; the
|
|
7
|
+
// request path is transport.js, the operation table and the verbs are generated, and the
|
|
8
|
+
// three printers are copyout.js.
|
|
9
|
+
//
|
|
10
|
+
// No build step and no bundler: this is an ES module the browser executes as it stands, and
|
|
11
|
+
// it loads nothing from anywhere but this origin. The appliance is expected to run
|
|
12
|
+
// air-gapped, and a page that renders blank without internet is worse than no page.
|
|
13
|
+
|
|
14
|
+
export { JmftsClient } from "./verbs.js";
|
|
15
|
+
export { OPERATIONS, OP_IDS } from "./operations.js";
|
|
16
|
+
export {
|
|
17
|
+
CALL_EVENT_FIELDS,
|
|
18
|
+
CREDENTIAL_KEYS,
|
|
19
|
+
TOKEN_KEY,
|
|
20
|
+
JmftsHttpError,
|
|
21
|
+
JmftsTransport,
|
|
22
|
+
JmftsTransportError,
|
|
23
|
+
credentialStorage,
|
|
24
|
+
detailOf,
|
|
25
|
+
} from "./transport.js";
|
|
26
|
+
export { asCurl, asFetch, asPython } from "./copyout.js";
|