@shortlink-org/portolan 0.2.4 → 0.3.0
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.
- package/README.md +23 -0
- package/catalog/enum_test.go +46 -0
- package/catalog/evidence_test.go +35 -0
- package/catalog/model.go +1066 -0
- package/catalog/roundtrip_test.go +203 -0
- package/catalog/via_test.go +38 -0
- package/cli/init.test.mjs +6 -1
- package/cli/portolan.mjs +8 -0
- package/cli/portolan.test.mjs +49 -0
- package/go.mod +14 -0
- package/go.sum +20 -0
- package/internal/gocall/README.md +19 -0
- package/internal/gocall/analyze.go +189 -0
- package/internal/gocall/analyze_test.go +107 -0
- package/internal/gohttp/analyze.go +2562 -0
- package/internal/gohttp/destination.go +373 -0
- package/internal/gohttp/endpoints.go +1067 -0
- package/internal/gohttp/roots.go +320 -0
- package/internal/gohttp/typed.go +96 -0
- package/internal/goscan/constants.go +85 -0
- package/internal/goscan/goscan_test.go +227 -0
- package/internal/goscan/index.go +629 -0
- package/internal/goscan/index_test.go +66 -0
- package/internal/goscan/names.go +52 -0
- package/internal/goscan/parse_test.go +11 -0
- package/internal/goscan/source.go +37 -0
- package/internal/goscan/tree.go +284 -0
- package/internal/goscan/types.go +99 -0
- package/internal/wsdl/ids.go +127 -0
- package/internal/wsdl/ids_test.go +21 -0
- package/internal/wsdl/model.go +70 -0
- package/internal/wsdl/parse.go +949 -0
- package/internal/wsdl/parse_test.go +170 -0
- package/package.json +22 -10
- package/plugin/describe.go +118 -0
- package/plugin/describe_test.go +114 -0
- package/plugin/protocol.go +141 -0
- package/plugin/schematest/schematest.go +126 -0
- package/plugins/README.md +109 -46
- package/plugins/cmd/portolan-http-clients/main.go +19 -0
- package/plugins/extract-celery/extract.py +0 -2
- package/plugins/extract-celery/extract_test.py +1 -1
- package/plugins/extract-django/README.md +39 -17
- package/plugins/extract-django/domain.py +28 -17
- package/plugins/extract-django/extract.py +21 -7
- package/plugins/extract-django/extract_test.py +55 -2
- package/plugins/extract-django/lifecycle.py +2 -0
- package/plugins/extract-django/operations.py +1 -1
- package/plugins/extract-django/routing_test.py +109 -1
- package/plugins/extract-django/store.py +1 -1
- package/plugins/extract-django/transport.py +101 -55
- package/plugins/extract-django/verbs.py +241 -0
- package/plugins/extract-go/README.md +47 -0
- package/plugins/extract-http-clients/describe.go +19 -0
- package/plugins/extract-http-clients/describe_test.go +11 -0
- package/plugins/extract-http-clients/extract.go +740 -0
- package/plugins/extract-http-clients/extract_test.go +1561 -0
- package/plugins/extract-http-clients/main.go +41 -0
- package/plugins/extract-java/build/org/portolan/extract/Extract.class +0 -0
- package/plugins/extract-java/build/org/portolan/extract/Protocol$Builder.class +0 -0
- package/plugins/extract-java/build/org/portolan/extract/Protocol$Input.class +0 -0
- package/plugins/extract-java/build/org/portolan/extract/Protocol$Options.class +0 -0
- package/plugins/extract-python-kafka/extract.py +0 -2
- package/plugins/extract-python-kafka/extract_test.py +1 -1
- package/plugins/extract-ts/extract.test.ts +2 -2
- package/plugins/extract-ts/extract.ts +4 -5
- package/plugins/extract-ts/graphql.test.ts +1 -1
- package/plugins/openapi/ids.go +261 -0
- package/plugins/openapi/ids_test.go +98 -0
- package/plugins/portolan-go.wasm +0 -0
- package/plugins/pyplugin/protocol.py +1 -5
- package/portolan.json +3 -2
- package/schema/portolan.schema.json +34 -0
- package/scripts/README.md +18 -12
- package/scripts/catalog-sources.mjs +6 -0
- package/scripts/delivery-presets.mjs +21 -11
- package/scripts/diff.mjs +5 -1
- package/scripts/django-aggregates.test.mjs +58 -0
- package/scripts/gen-likec4.mjs +1 -1
- package/scripts/gen.mjs +118 -115
- package/scripts/go-discovery.test.mjs +30 -0
- package/scripts/history.mjs +186 -3
- package/scripts/history.test.mjs +1 -1
- package/scripts/host-plugins/fetch-git.mjs +77 -21
- package/scripts/host-plugins/fetch-git.test.mjs +62 -8
- package/scripts/local-api.mjs +71 -4
- package/scripts/local-api.test.mjs +63 -4
- package/scripts/local-discovery.mjs +82 -9
- package/scripts/manifest.mjs +5 -3
- package/scripts/manifest.test.mjs +24 -0
- package/scripts/output-diff.mjs +94 -0
- package/scripts/output-diff.test.mjs +36 -0
- package/scripts/package-smoke.mjs +62 -4
- package/scripts/plugin-host.mjs +22 -2
- package/scripts/plugin-host.test.mjs +9 -0
- package/scripts/plugin-wasm-worker.mjs +4 -1
- package/scripts/provenance.mjs +72 -0
- package/scripts/provenance.test.mjs +149 -0
- package/scripts/run-builtin.mjs +39 -5
- package/scripts/schema.mjs +29 -0
- package/scripts/warning-policy.mjs +161 -0
- package/scripts/warning-policy.test.mjs +56 -0
- package/src/app/Sidebar.tsx +3 -3
- package/src/catalog-docs.test.ts +64 -0
- package/src/catalog-docs.ts +35 -0
- package/src/catalog-error.test.ts +15 -0
- package/src/catalog-model.ts +48 -5
- package/src/catalog-validation.ts +9 -0
- package/src/chat/Starter.tsx +5 -11
- package/src/chat/tools.test.ts +27 -0
- package/src/chat/tools.ts +5 -9
- package/src/components/CatalogStamp.tsx +10 -8
- package/src/components/HTTPDestinationEvidence.test.tsx +23 -0
- package/src/components/HTTPDestinationEvidence.tsx +31 -0
- package/src/components/Integrations.tsx +1 -1
- package/src/components/MachineDocs.tsx +6 -5
- package/src/components/MethodRows.tsx +9 -2
- package/src/components/RelationEvidence.test.tsx +14 -0
- package/src/components/RelationEvidence.tsx +53 -0
- package/src/data.ts +25 -7
- package/src/enrich.test.ts +332 -1
- package/src/enrich.ts +206 -3
- package/src/flow/StepDetail.tsx +6 -0
- package/src/flow/evidence.test.ts +16 -0
- package/src/flow/evidence.ts +34 -0
- package/src/index.css +44 -0
- package/src/landing/DraggableReveal.tsx +3 -2
- package/src/landing/EvidencePipeline.tsx +105 -0
- package/src/landing/LandingPage.tsx +2 -59
- package/src/lib/catalog-diff.ts +1 -1
- package/src/lib/django-aggregates.d.mts +9 -0
- package/src/lib/django-aggregates.mjs +36 -0
- package/src/lib/django-aggregates.test.ts +29 -0
- package/src/lib/django-aggregates.ts +5 -0
- package/src/lib/local-api.ts +20 -2
- package/src/lib/setup-info.test.ts +17 -0
- package/src/lib/setup-info.ts +58 -0
- package/src/lib/warnings.test.ts +54 -0
- package/src/lib/warnings.ts +260 -0
- package/src/map/ContextMapGraph.tsx +76 -32
- package/src/merge.ts +16 -9
- package/src/pages/AggregatePage.tsx +8 -7
- package/src/pages/ContextPage.tsx +6 -5
- package/src/pages/ServicePage.tsx +4 -3
- package/src/pages/Settings.tsx +189 -41
- package/src/pages/settings/DjangoAggregateChoices.tsx +79 -0
- package/src/selection/DetailPanel.tsx +15 -0
- package/src/virtual-provenance.d.ts +11 -0
- package/vite.config.ts +5 -0
- package/scripts/vendor-lock.mjs +0 -58
- package/scripts/vendor-lock.test.mjs +0 -69
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// Package extracthttpclients is portolan-extract-http-clients: outbound HTTP and SOAP calls
|
|
2
|
+
// in a Go repository in, source-backed dependencies and flows out.
|
|
3
|
+
package extracthttpclients
|
|
4
|
+
|
|
5
|
+
import (
|
|
6
|
+
"fmt"
|
|
7
|
+
"io"
|
|
8
|
+
"path/filepath"
|
|
9
|
+
|
|
10
|
+
"github.com/shortlink-org/portolan/plugin"
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
type Options struct {
|
|
14
|
+
Context string `json:"context"`
|
|
15
|
+
Service string `json:"service"`
|
|
16
|
+
Peers map[string]string `json:"peers,omitempty"`
|
|
17
|
+
Externals map[string]string `json:"externals,omitempty"`
|
|
18
|
+
Out string `json:"out,omitempty"`
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Serve answers one request on stdin with one response on stdout. The
|
|
22
|
+
// process entrypoint and the wasm dispatcher in plugins/cmd/portolan-go both
|
|
23
|
+
// call it.
|
|
24
|
+
func Serve(stdin io.Reader, stdout io.Writer) error {
|
|
25
|
+
return run(stdin, stdout)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
func run(stdin io.Reader, stdout io.Writer) error {
|
|
29
|
+
return plugin.Serve(stdin, stdout, descriptor(), func(req plugin.Request, opts Options) (plugin.Response, error) {
|
|
30
|
+
if req.Input.Root == "" {
|
|
31
|
+
return plugin.Response{}, fmt.Errorf("no input root: an extractor has nothing to read")
|
|
32
|
+
}
|
|
33
|
+
if opts.Context == "" {
|
|
34
|
+
opts.Context = slug(filepath.Base(req.Input.Root))
|
|
35
|
+
}
|
|
36
|
+
if opts.Service == "" {
|
|
37
|
+
opts.Service = slug(filepath.Base(req.Input.Root))
|
|
38
|
+
}
|
|
39
|
+
return extract(req.Input, opts)
|
|
40
|
+
})
|
|
41
|
+
}
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -62,8 +62,6 @@ def extract(input_: Input, opts: Options, b: Builder, cwd: str = "") -> None:
|
|
|
62
62
|
flows.sort(key=lambda flow: flow["slug"])
|
|
63
63
|
|
|
64
64
|
fragment: Dict[str, Any] = {
|
|
65
|
-
"generatedAt": input_.generated_at,
|
|
66
|
-
"commit": input_.commit,
|
|
67
65
|
"contexts": [
|
|
68
66
|
{
|
|
69
67
|
"id": context,
|
|
@@ -23,7 +23,7 @@ class PythonKafka(unittest.TestCase):
|
|
|
23
23
|
fixture = os.path.join(HERE, "testdata", "service")
|
|
24
24
|
builder = Builder()
|
|
25
25
|
extract(
|
|
26
|
-
Input(root=os.path.relpath(fixture, ROOT)
|
|
26
|
+
Input(root=os.path.relpath(fixture, ROOT)),
|
|
27
27
|
Options.of({"context": "shop", "service": "orders", "settings": "config.settings"}),
|
|
28
28
|
builder,
|
|
29
29
|
cwd=ROOT,
|
|
@@ -8,7 +8,7 @@ const ROOT = "plugins/extract-ts/testdata/cart";
|
|
|
8
8
|
const options = { context: "shop", service: "cart", store: "pg", peers: { "auth.v1": "auth.auth", "shop.v1": "shop.pricing" } };
|
|
9
9
|
|
|
10
10
|
function run() {
|
|
11
|
-
return extract({ root: ROOT
|
|
11
|
+
return extract({ root: ROOT }, options);
|
|
12
12
|
}
|
|
13
13
|
|
|
14
14
|
function fragment() {
|
|
@@ -200,7 +200,7 @@ describe("the JavaScript twin", () => {
|
|
|
200
200
|
);
|
|
201
201
|
|
|
202
202
|
it("reads to the fragment the TypeScript tree reads to", () => {
|
|
203
|
-
const twin = extract({ root: JS
|
|
203
|
+
const twin = extract({ root: JS }, options);
|
|
204
204
|
const golden = JSON.parse(readFileSync(`${ROOT}/expected.json`, "utf8"));
|
|
205
205
|
expect(sameShape(JSON.parse(twin.files[0]!.contents))).toEqual(sameShape(golden));
|
|
206
206
|
expect(twin.warnings.map((d) => d.ref)).toEqual(["getBasket"]);
|
|
@@ -5,6 +5,9 @@
|
|
|
5
5
|
import { existsSync, readFileSync, realpathSync } from "node:fs";
|
|
6
6
|
import { basename, join, relative, resolve } from "node:path";
|
|
7
7
|
import type { Catalog, Service } from "../../src/catalog.ts";
|
|
8
|
+
|
|
9
|
+
/** What an extractor writes: a catalog less the stamps, which are the history's to give (portolan.0010). */
|
|
10
|
+
type Fragment = Omit<Catalog, "generatedAt" | "commit">;
|
|
8
11
|
import { readAggregates, type WarningSink } from "./domain.ts";
|
|
9
12
|
import { operationOf, readUseCases } from "./operations.ts";
|
|
10
13
|
import { readBindings } from "./wiring.ts";
|
|
@@ -39,8 +42,6 @@ export interface Options {
|
|
|
39
42
|
export interface Input {
|
|
40
43
|
root: string;
|
|
41
44
|
output?: string;
|
|
42
|
-
commit: string;
|
|
43
|
-
generatedAt: string;
|
|
44
45
|
}
|
|
45
46
|
|
|
46
47
|
export interface Warning {
|
|
@@ -151,9 +152,7 @@ export function extract(input: Input, opts: Options, cwd = process.cwd()): Respo
|
|
|
151
152
|
for (const e of source.errors) b.warn(rel(source.path), `${rel(e.at)}: ${e.message}; the file is read only up to here`);
|
|
152
153
|
}
|
|
153
154
|
|
|
154
|
-
const fragment:
|
|
155
|
-
generatedAt: input.generatedAt,
|
|
156
|
-
commit: input.commit,
|
|
155
|
+
const fragment: Fragment = {
|
|
157
156
|
contexts: [
|
|
158
157
|
{
|
|
159
158
|
id: context,
|
|
@@ -13,7 +13,7 @@ const ROOT = "plugins/extract-ts/testdata/storefront";
|
|
|
13
13
|
const options = { context: "storefront", service: "bff", graphql: "src/schema", peers: { "auth.v1": "auth.auth" } };
|
|
14
14
|
|
|
15
15
|
function run() {
|
|
16
|
-
return extract({ root: ROOT
|
|
16
|
+
return extract({ root: ROOT }, options);
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
function fragment() {
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
// Package openapi is what two extractors agree on about an OpenAPI document:
|
|
2
|
+
// how its interfaces are named in the catalog, and which operation answers on
|
|
3
|
+
// which route. The server side reads the document to say what a service
|
|
4
|
+
// provides; the client side reads the copy vendored beside a generated client
|
|
5
|
+
// to say what a service calls. Both have to spell `auth.v1.Sessions/login` the
|
|
6
|
+
// same way, or the call would never resolve to the method, and one place is
|
|
7
|
+
// how they do.
|
|
8
|
+
package openapi
|
|
9
|
+
|
|
10
|
+
import (
|
|
11
|
+
"fmt"
|
|
12
|
+
"os"
|
|
13
|
+
"strings"
|
|
14
|
+
|
|
15
|
+
"gopkg.in/yaml.v3"
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
// APIID is the document's title and major version: `auth` 1.0.0 gives
|
|
19
|
+
// `auth.v1`. It prefixes every interface the document declares.
|
|
20
|
+
func APIID(title, version string) string {
|
|
21
|
+
name := title
|
|
22
|
+
if name == "" {
|
|
23
|
+
name = "api"
|
|
24
|
+
}
|
|
25
|
+
name = strings.ReplaceAll(strings.ToLower(name), " ", "-")
|
|
26
|
+
|
|
27
|
+
if major, _, ok := strings.Cut(version, "."); ok && major != "" {
|
|
28
|
+
return name + ".v" + major
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return name
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// DocumentAPIID is the id a document says it goes by in the estate, or the one
|
|
35
|
+
// built from its title and version when it says nothing.
|
|
36
|
+
//
|
|
37
|
+
// `x-portolan-api` in `info` is for a copy vendored from outside the estate:
|
|
38
|
+
// Stripe's document is titled "Stripe API" and versioned "2026-08-26.dahlia",
|
|
39
|
+
// and `stripe-api.v2026-08-26` on every arrow would be the document's words
|
|
40
|
+
// where the estate wants its own. The copy is the consumer's translation
|
|
41
|
+
// boundary already, so it is the one place the estate's name may be written,
|
|
42
|
+
// and both sides of a call read it from there rather than from two manifests.
|
|
43
|
+
func DocumentAPIID(declared, title, version string) string {
|
|
44
|
+
if declared = strings.TrimSpace(declared); declared != "" {
|
|
45
|
+
return declared
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return APIID(title, version)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ExternalID is the id a system outside the estate goes by when nothing but
|
|
52
|
+
// its own document names it: the document's title as a slug, with a trailing
|
|
53
|
+
// "API" dropped because the system is the thing, not its interface. "Gordian
|
|
54
|
+
// Flights & Ancillaries API" is gordian-flights-ancillaries; "Stripe API" is
|
|
55
|
+
// stripe. Both sides of a call - the extractor that reads the vendored copy
|
|
56
|
+
// for what the system answers on, and the one that reads the client beside it
|
|
57
|
+
// for what this service calls - derive it here, so neither needs the manifest
|
|
58
|
+
// to say it.
|
|
59
|
+
func ExternalID(title string) string {
|
|
60
|
+
var b strings.Builder
|
|
61
|
+
dash := false
|
|
62
|
+
for _, r := range strings.ToLower(strings.TrimSpace(title)) {
|
|
63
|
+
switch {
|
|
64
|
+
case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
|
|
65
|
+
b.WriteRune(r)
|
|
66
|
+
dash = false
|
|
67
|
+
case !dash && b.Len() > 0:
|
|
68
|
+
b.WriteByte('-')
|
|
69
|
+
dash = true
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
id := strings.TrimRight(b.String(), "-")
|
|
73
|
+
if trimmed := strings.TrimSuffix(id, "-api"); trimmed != "" {
|
|
74
|
+
id = trimmed
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return id
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Title is the human form of a tag: users becomes Users, price_list becomes
|
|
81
|
+
// PriceList, because it sits in an id beside a proto-shaped service name.
|
|
82
|
+
func Title(name string) string {
|
|
83
|
+
var b strings.Builder
|
|
84
|
+
for _, word := range strings.FieldsFunc(name, func(r rune) bool { return r == '_' || r == '-' || r == ' ' }) {
|
|
85
|
+
runes := []rune(word)
|
|
86
|
+
if runes[0] >= 'a' && runes[0] <= 'z' {
|
|
87
|
+
runes[0] = runes[0] - 'a' + 'A'
|
|
88
|
+
}
|
|
89
|
+
b.WriteString(string(runes))
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return b.String()
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// InterfaceID is the contract declared by the OpenAPI document. Tags organise
|
|
96
|
+
// operations inside that contract; they do not create separate interfaces.
|
|
97
|
+
func InterfaceID(api, _ string) string { return api }
|
|
98
|
+
|
|
99
|
+
// Verbs, in the order the paths object lists them by convention.
|
|
100
|
+
var Verbs = []string{"get", "put", "post", "delete", "options", "head", "patch", "trace"}
|
|
101
|
+
|
|
102
|
+
// Operation is one route of the document and the name it goes by.
|
|
103
|
+
type Operation struct {
|
|
104
|
+
// ID is the operationId, or `VERB /path` when the document has none.
|
|
105
|
+
ID string
|
|
106
|
+
// Tag is the first tag, retained as document metadata. It does not change
|
|
107
|
+
// the identity of the contract.
|
|
108
|
+
Tag string
|
|
109
|
+
// Verb is upper case: POST.
|
|
110
|
+
Verb string
|
|
111
|
+
// Path is the template as written: /v1/users/{userId}.
|
|
112
|
+
Path string
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Interface is the id of the interface the operation belongs to.
|
|
116
|
+
func (o Operation) Interface(api string) string { return InterfaceID(api, o.Tag) }
|
|
117
|
+
|
|
118
|
+
// CallID is what a call to this operation is known by in the catalog.
|
|
119
|
+
func (o Operation) CallID(api string) string { return o.Interface(api) + "/" + o.ID }
|
|
120
|
+
|
|
121
|
+
// Spec is the part of a document these extractors read.
|
|
122
|
+
type Spec struct {
|
|
123
|
+
API string
|
|
124
|
+
Operations []Operation
|
|
125
|
+
|
|
126
|
+
// Title, Description and DocsURL are what the document says about the
|
|
127
|
+
// system it describes, for naming that system when the manifest does not.
|
|
128
|
+
Title string
|
|
129
|
+
Description string
|
|
130
|
+
DocsURL string
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Read loads a document and lists its operations. The api id may be
|
|
134
|
+
// overridden by whoever calls, since the manifest may name one.
|
|
135
|
+
func Read(path string) (*Spec, error) {
|
|
136
|
+
contents, err := os.ReadFile(path)
|
|
137
|
+
if err != nil {
|
|
138
|
+
return nil, err
|
|
139
|
+
}
|
|
140
|
+
var root yaml.Node
|
|
141
|
+
if err := yaml.Unmarshal(contents, &root); err != nil {
|
|
142
|
+
return nil, fmt.Errorf("%s: %w", path, err)
|
|
143
|
+
}
|
|
144
|
+
node := &root
|
|
145
|
+
if node.Kind == yaml.DocumentNode && len(node.Content) > 0 {
|
|
146
|
+
node = node.Content[0]
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
info := child(node, "info")
|
|
150
|
+
spec := &Spec{
|
|
151
|
+
API: DocumentAPIID(text(child(info, "x-portolan-api")), text(child(info, "title")), text(child(info, "version"))),
|
|
152
|
+
Title: text(child(info, "title")),
|
|
153
|
+
Description: text(child(info, "description")),
|
|
154
|
+
DocsURL: text(child(node, "externalDocs", "url")),
|
|
155
|
+
}
|
|
156
|
+
for _, p := range entries(child(node, "paths")) {
|
|
157
|
+
for _, verb := range Verbs {
|
|
158
|
+
operation := child(p.value, verb)
|
|
159
|
+
if operation == nil {
|
|
160
|
+
continue
|
|
161
|
+
}
|
|
162
|
+
id := text(child(operation, "operationId"))
|
|
163
|
+
if id == "" {
|
|
164
|
+
id = strings.ToUpper(verb) + " " + p.key
|
|
165
|
+
}
|
|
166
|
+
tags := list(child(operation, "tags"))
|
|
167
|
+
tag := ""
|
|
168
|
+
if len(tags) > 0 {
|
|
169
|
+
tag = tags[0]
|
|
170
|
+
}
|
|
171
|
+
spec.Operations = append(spec.Operations, Operation{ID: id, Tag: tag, Verb: strings.ToUpper(verb), Path: p.key})
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return spec, nil
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Find looks up an operation by verb and route. Both sides may spell a path
|
|
179
|
+
// parameter differently - `{userId}` in the document, `%s` in a generated
|
|
180
|
+
// client - so parameters are compared by position, not by name.
|
|
181
|
+
func (s *Spec) Find(verb, path string) (Operation, bool) {
|
|
182
|
+
want := strings.ToUpper(verb) + " " + shape(path)
|
|
183
|
+
for _, op := range s.Operations {
|
|
184
|
+
if op.Verb+" "+shape(op.Path) == want {
|
|
185
|
+
return op, true
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return Operation{}, false
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// shape replaces every parameter, however spelled, with one marker.
|
|
193
|
+
func shape(path string) string {
|
|
194
|
+
var b strings.Builder
|
|
195
|
+
for _, segment := range strings.Split(strings.TrimSuffix(path, "/"), "/") {
|
|
196
|
+
if strings.HasPrefix(segment, "{") || segment == "%s" || segment == "%v" || segment == "%d" {
|
|
197
|
+
b.WriteString("/*")
|
|
198
|
+
|
|
199
|
+
continue
|
|
200
|
+
}
|
|
201
|
+
b.WriteString("/" + segment)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return b.String()
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
type entry struct {
|
|
208
|
+
key string
|
|
209
|
+
value *yaml.Node
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
func entries(node *yaml.Node) []entry {
|
|
213
|
+
if node == nil || node.Kind != yaml.MappingNode {
|
|
214
|
+
return nil
|
|
215
|
+
}
|
|
216
|
+
var out []entry
|
|
217
|
+
for i := 0; i+1 < len(node.Content); i += 2 {
|
|
218
|
+
out = append(out, entry{key: node.Content[i].Value, value: node.Content[i+1]})
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return out
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
func child(node *yaml.Node, keys ...string) *yaml.Node {
|
|
225
|
+
for _, key := range keys {
|
|
226
|
+
if node == nil || node.Kind != yaml.MappingNode {
|
|
227
|
+
return nil
|
|
228
|
+
}
|
|
229
|
+
var next *yaml.Node
|
|
230
|
+
for i := 0; i+1 < len(node.Content); i += 2 {
|
|
231
|
+
if node.Content[i].Value == key {
|
|
232
|
+
next = node.Content[i+1]
|
|
233
|
+
|
|
234
|
+
break
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
node = next
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return node
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
func text(node *yaml.Node) string {
|
|
244
|
+
if node == nil || node.Kind != yaml.ScalarNode {
|
|
245
|
+
return ""
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return strings.TrimSpace(node.Value)
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
func list(node *yaml.Node) []string {
|
|
252
|
+
if node == nil || node.Kind != yaml.SequenceNode {
|
|
253
|
+
return nil
|
|
254
|
+
}
|
|
255
|
+
var out []string
|
|
256
|
+
for _, item := range node.Content {
|
|
257
|
+
out = append(out, text(item))
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return out
|
|
261
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
package openapi
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"os"
|
|
5
|
+
"path/filepath"
|
|
6
|
+
"testing"
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
func TestIDs(t *testing.T) {
|
|
10
|
+
if got := APIID("Auth", "1.2.3"); got != "auth.v1" {
|
|
11
|
+
t.Errorf("APIID = %q", got)
|
|
12
|
+
}
|
|
13
|
+
if got := APIID("", ""); got != "api" {
|
|
14
|
+
t.Errorf("APIID of nothing = %q", got)
|
|
15
|
+
}
|
|
16
|
+
if got := Title("price_list"); got != "PriceList" {
|
|
17
|
+
t.Errorf("Title = %q", got)
|
|
18
|
+
}
|
|
19
|
+
if got := InterfaceID("auth.v1", "sessions"); got != "auth.v1" {
|
|
20
|
+
t.Errorf("InterfaceID = %q", got)
|
|
21
|
+
}
|
|
22
|
+
if got := InterfaceID("auth.v1", ""); got != "auth.v1" {
|
|
23
|
+
t.Errorf("InterfaceID without a tag = %q", got)
|
|
24
|
+
}
|
|
25
|
+
// A vendored copy may say what the estate calls it; a document that says
|
|
26
|
+
// nothing is named the way it always was.
|
|
27
|
+
if got := DocumentAPIID(" stripe.v1 ", "Stripe API", "2026-08-26.dahlia"); got != "stripe.v1" {
|
|
28
|
+
t.Errorf("DocumentAPIID with x-portolan-api = %q", got)
|
|
29
|
+
}
|
|
30
|
+
if got := DocumentAPIID("", "Stripe API", "2026-08-26.dahlia"); got != "stripe-api.v2026-08-26" {
|
|
31
|
+
t.Errorf("DocumentAPIID without = %q", got)
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// A system outside the estate is named by its document's title, and the
|
|
36
|
+
// title's "API" is the interface, not the system.
|
|
37
|
+
func TestExternalID(t *testing.T) {
|
|
38
|
+
for title, want := range map[string]string{
|
|
39
|
+
"Gordian Flights & Ancillaries API": "gordian-flights-ancillaries",
|
|
40
|
+
"Stripe API": "stripe",
|
|
41
|
+
" Partner REST API ": "partner-rest",
|
|
42
|
+
"API": "api",
|
|
43
|
+
"": "",
|
|
44
|
+
"Amadeus (Self-Service) v2": "amadeus-self-service-v2",
|
|
45
|
+
} {
|
|
46
|
+
if got := ExternalID(title); got != want {
|
|
47
|
+
t.Errorf("ExternalID(%q) = %q, want %q", title, got, want)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const doc = `openapi: 3.0.3
|
|
53
|
+
info:
|
|
54
|
+
title: auth
|
|
55
|
+
version: 1.0.0
|
|
56
|
+
paths:
|
|
57
|
+
/v1/sessions:
|
|
58
|
+
post:
|
|
59
|
+
operationId: login
|
|
60
|
+
tags: [sessions]
|
|
61
|
+
/v1/users/{userId}:
|
|
62
|
+
get:
|
|
63
|
+
operationId: getUser
|
|
64
|
+
tags: [users]
|
|
65
|
+
/v1/health:
|
|
66
|
+
get: {}
|
|
67
|
+
`
|
|
68
|
+
|
|
69
|
+
func TestReadAndFind(t *testing.T) {
|
|
70
|
+
path := filepath.Join(t.TempDir(), "openapi.yaml")
|
|
71
|
+
if err := os.WriteFile(path, []byte(doc), 0o644); err != nil {
|
|
72
|
+
t.Fatal(err)
|
|
73
|
+
}
|
|
74
|
+
spec, err := Read(path)
|
|
75
|
+
if err != nil {
|
|
76
|
+
t.Fatal(err)
|
|
77
|
+
}
|
|
78
|
+
if spec.API != "auth.v1" || len(spec.Operations) != 3 {
|
|
79
|
+
t.Fatalf("spec = %+v", spec)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
login, ok := spec.Find("post", "/v1/sessions")
|
|
83
|
+
if !ok || login.CallID(spec.API) != "auth.v1/login" {
|
|
84
|
+
t.Errorf("login = %+v %v", login, ok)
|
|
85
|
+
}
|
|
86
|
+
// A generated client spells the parameter as a format verb.
|
|
87
|
+
user, ok := spec.Find("GET", "/v1/users/%s")
|
|
88
|
+
if !ok || user.CallID(spec.API) != "auth.v1/getUser" {
|
|
89
|
+
t.Errorf("getUser = %+v %v", user, ok)
|
|
90
|
+
}
|
|
91
|
+
health, ok := spec.Find("GET", "/v1/health")
|
|
92
|
+
if !ok || health.CallID(spec.API) != "auth.v1/GET /v1/health" {
|
|
93
|
+
t.Errorf("an operation without an id or tag = %+v %v", health, ok)
|
|
94
|
+
}
|
|
95
|
+
if _, ok := spec.Find("DELETE", "/v1/sessions"); ok {
|
|
96
|
+
t.Error("a verb the document does not declare was found")
|
|
97
|
+
}
|
|
98
|
+
}
|
package/plugins/portolan-go.wasm
CHANGED
|
Binary file
|
|
@@ -17,12 +17,10 @@ from typing import Any, Dict, List
|
|
|
17
17
|
|
|
18
18
|
@dataclass
|
|
19
19
|
class Input:
|
|
20
|
-
"""Where the source is
|
|
20
|
+
"""Where the source is."""
|
|
21
21
|
|
|
22
22
|
root: str = ""
|
|
23
23
|
output: str = ""
|
|
24
|
-
commit: str = ""
|
|
25
|
-
generated_at: str = ""
|
|
26
24
|
|
|
27
25
|
@staticmethod
|
|
28
26
|
def of(raw: Any) -> "Input":
|
|
@@ -30,8 +28,6 @@ class Input:
|
|
|
30
28
|
return Input(
|
|
31
29
|
root=raw.get("root", ""),
|
|
32
30
|
output=raw.get("output", ""),
|
|
33
|
-
commit=raw.get("commit", ""),
|
|
34
|
-
generated_at=raw.get("generatedAt", ""),
|
|
35
31
|
)
|
|
36
32
|
|
|
37
33
|
|
package/portolan.json
CHANGED
|
@@ -44,6 +44,14 @@
|
|
|
44
44
|
},
|
|
45
45
|
"description": "The source projects that make up the estate. A project gives repeated pipeline inputs one name for the generated site's Settings page; estate-wide inputs such as flows need no project."
|
|
46
46
|
},
|
|
47
|
+
"warningPolicies": {
|
|
48
|
+
"type": "array",
|
|
49
|
+
"maxItems": 100,
|
|
50
|
+
"items": {
|
|
51
|
+
"$ref": "#/$defs/warningPolicy"
|
|
52
|
+
},
|
|
53
|
+
"description": "CEL policies for reviewed extraction limitations. Expressions are type-checked when the manifest is read and suppression always requires a reason."
|
|
54
|
+
},
|
|
47
55
|
"plugins": {
|
|
48
56
|
"type": "array",
|
|
49
57
|
"items": {
|
|
@@ -795,6 +803,32 @@
|
|
|
795
803
|
}
|
|
796
804
|
},
|
|
797
805
|
"$defs": {
|
|
806
|
+
"warningPolicy": {
|
|
807
|
+
"type": "object",
|
|
808
|
+
"additionalProperties": false,
|
|
809
|
+
"required": [
|
|
810
|
+
"when",
|
|
811
|
+
"action",
|
|
812
|
+
"reason"
|
|
813
|
+
],
|
|
814
|
+
"properties": {
|
|
815
|
+
"when": {
|
|
816
|
+
"type": "string",
|
|
817
|
+
"minLength": 1,
|
|
818
|
+
"maxLength": 1000,
|
|
819
|
+
"description": "Boolean CEL expression over plugin, rule, severity, project, phase, ref, message and count."
|
|
820
|
+
},
|
|
821
|
+
"action": {
|
|
822
|
+
"const": "suppress",
|
|
823
|
+
"description": "Suppress matching diagnostics from the active view while retaining them in the report."
|
|
824
|
+
},
|
|
825
|
+
"reason": {
|
|
826
|
+
"type": "string",
|
|
827
|
+
"minLength": 1,
|
|
828
|
+
"description": "Why this limitation is consciously accepted."
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
},
|
|
798
832
|
"catalogProfile": {
|
|
799
833
|
"type": "object",
|
|
800
834
|
"additionalProperties": false,
|
package/scripts/README.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# Host
|
|
2
2
|
|
|
3
3
|
The side of portolan that runs. `npm run gen` reads the manifest, runs every
|
|
4
|
-
plugin it names,
|
|
5
|
-
|
|
4
|
+
plugin it names, writes what comes back, merges the sources into one estate
|
|
5
|
+
and validates the union. Nothing here draws anything.
|
|
6
6
|
|
|
7
7
|
## What it does
|
|
8
8
|
|
|
@@ -10,21 +10,25 @@ one estate and validates the union. Nothing here draws anything.
|
|
|
10
10
|
the schema `npm run schema` composes out of what every declared plugin says
|
|
11
11
|
it can be told.
|
|
12
12
|
- Runs three phases in order, extract, verify, generate, and hands each
|
|
13
|
-
plugin one JSON message: the input root
|
|
13
|
+
plugin one JSON message: the input root and output directory for an
|
|
14
14
|
extractor, the merged catalog for a verifier or a generator, the step's
|
|
15
15
|
options unread. The output path lets a fragment point at a companion file
|
|
16
16
|
returned in the same response without letting the plugin write it itself.
|
|
17
|
-
-
|
|
18
|
-
|
|
19
|
-
|
|
17
|
+
- Writes no provenance into a fragment. When a source last changed, and in
|
|
18
|
+
which commit, is read from the history of the checkout wherever the catalog
|
|
19
|
+
is read - here, by the LikeC4 generator, by the architecture diff and by
|
|
20
|
+
the site as Vite builds it - so a fragment is content and nothing else, and
|
|
21
|
+
regenerating from the same sources changes no byte (portolan.0010).
|
|
20
22
|
- Writes the files a plugin named, refuses a name that climbs out of the
|
|
21
23
|
step's output directory, keeps a listing per step of what it wrote, and
|
|
22
24
|
deletes what stopped being generated (portolan.0001).
|
|
23
25
|
- Merges every source the manifest's globs find and validates the union with
|
|
24
26
|
the code the site runs: `src/merge.ts`, `src/catalog.ts`, `src/enrich.ts`.
|
|
25
27
|
- In `--check` mode writes nothing and fails on the first file that differs
|
|
26
|
-
from disk
|
|
27
|
-
|
|
28
|
+
from disk, saying where the file first differs and what changed among the
|
|
29
|
+
step's inputs since its output was last committed - the history is the
|
|
30
|
+
record of the last generation, so nothing else has to be. Either way it
|
|
31
|
+
leaves `.portolan/build-report.json` for the Settings page.
|
|
28
32
|
|
|
29
33
|
## What it does not do
|
|
30
34
|
|
|
@@ -47,7 +51,9 @@ one estate and validates the union. Nothing here draws anything.
|
|
|
47
51
|
that report put on the pull request as one comment kept current;
|
|
48
52
|
`forge-release.mjs`, the same report as one section of a release's notes;
|
|
49
53
|
`forge.mjs`, what those two share - which forge the CI is, and how to talk
|
|
50
|
-
to it; `
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
+
to it; `history.mjs`, what the checkout's history says about every file,
|
|
55
|
+
for a plugin that asks and for the provenance of every source;
|
|
56
|
+
`provenance.mjs`, that provenance handed to the site as one virtual module;
|
|
57
|
+
`output-diff.mjs`, where a generated file first differs from what the
|
|
58
|
+
generator produces; `site-docs.mjs`, generated documentation put into the
|
|
59
|
+
built site; `local-api.mjs`, what the dev server answers the site with.
|
|
@@ -14,6 +14,7 @@ import { validateCatalog } from "../src/catalog.ts";
|
|
|
14
14
|
import { filterCatalogForProfile } from "../src/catalog-profile.ts";
|
|
15
15
|
import { enrichCatalog } from "../src/enrich.ts";
|
|
16
16
|
import { mergeCatalogs } from "../src/merge.ts";
|
|
17
|
+
import { stampsFor } from "./history.mjs";
|
|
17
18
|
import { readManifest } from "./manifest.mjs";
|
|
18
19
|
|
|
19
20
|
/**
|
|
@@ -48,10 +49,15 @@ export async function loadCatalog(manifestPath = "portolan.json", { exclude = []
|
|
|
48
49
|
);
|
|
49
50
|
}
|
|
50
51
|
|
|
52
|
+
// A source is dated by the history, not by itself (portolan.0010): the
|
|
53
|
+
// commit that last changed the file, and its date, read here and never
|
|
54
|
+
// written into the file.
|
|
55
|
+
const stamps = stampsFor(process.cwd(), paths);
|
|
51
56
|
const merged = mergeCatalogs(
|
|
52
57
|
paths.map((path) => ({
|
|
53
58
|
path,
|
|
54
59
|
catalog: JSON.parse(readFileSync(path, "utf8")),
|
|
60
|
+
stamp: stamps.get(path),
|
|
55
61
|
})),
|
|
56
62
|
);
|
|
57
63
|
if (paths.length === 0) {
|