@shortlink-org/portolan 0.2.3 → 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.
Files changed (184) hide show
  1. package/README.md +23 -0
  2. package/catalog/enum_test.go +46 -0
  3. package/catalog/evidence_test.go +35 -0
  4. package/catalog/model.go +1066 -0
  5. package/catalog/roundtrip_test.go +203 -0
  6. package/catalog/via_test.go +38 -0
  7. package/cli/init.test.mjs +6 -1
  8. package/cli/portolan.mjs +8 -0
  9. package/cli/portolan.test.mjs +49 -0
  10. package/go.mod +14 -0
  11. package/go.sum +20 -0
  12. package/internal/gocall/README.md +19 -0
  13. package/internal/gocall/analyze.go +189 -0
  14. package/internal/gocall/analyze_test.go +107 -0
  15. package/internal/gohttp/analyze.go +2562 -0
  16. package/internal/gohttp/destination.go +373 -0
  17. package/internal/gohttp/endpoints.go +1067 -0
  18. package/internal/gohttp/roots.go +320 -0
  19. package/internal/gohttp/typed.go +96 -0
  20. package/internal/goscan/constants.go +85 -0
  21. package/internal/goscan/goscan_test.go +227 -0
  22. package/internal/goscan/index.go +629 -0
  23. package/internal/goscan/index_test.go +66 -0
  24. package/internal/goscan/names.go +52 -0
  25. package/internal/goscan/parse_test.go +11 -0
  26. package/internal/goscan/source.go +37 -0
  27. package/internal/goscan/tree.go +284 -0
  28. package/internal/goscan/types.go +99 -0
  29. package/internal/wsdl/ids.go +127 -0
  30. package/internal/wsdl/ids_test.go +21 -0
  31. package/internal/wsdl/model.go +70 -0
  32. package/internal/wsdl/parse.go +949 -0
  33. package/internal/wsdl/parse_test.go +170 -0
  34. package/package.json +22 -10
  35. package/plugin/describe.go +118 -0
  36. package/plugin/describe_test.go +114 -0
  37. package/plugin/protocol.go +141 -0
  38. package/plugin/schematest/schematest.go +126 -0
  39. package/plugins/README.md +114 -46
  40. package/plugins/cmd/portolan-http-clients/main.go +19 -0
  41. package/plugins/extract-celery/extract.py +0 -2
  42. package/plugins/extract-celery/extract_test.py +1 -1
  43. package/plugins/extract-django/README.md +39 -17
  44. package/plugins/extract-django/domain.py +28 -17
  45. package/plugins/extract-django/extract.py +21 -7
  46. package/plugins/extract-django/extract_test.py +55 -2
  47. package/plugins/extract-django/lifecycle.py +2 -0
  48. package/plugins/extract-django/operations.py +1 -1
  49. package/plugins/extract-django/routing_test.py +109 -1
  50. package/plugins/extract-django/store.py +1 -1
  51. package/plugins/extract-django/transport.py +101 -55
  52. package/plugins/extract-django/verbs.py +241 -0
  53. package/plugins/extract-go/README.md +47 -0
  54. package/plugins/extract-http-clients/describe.go +19 -0
  55. package/plugins/extract-http-clients/describe_test.go +11 -0
  56. package/plugins/extract-http-clients/extract.go +740 -0
  57. package/plugins/extract-http-clients/extract_test.go +1561 -0
  58. package/plugins/extract-http-clients/main.go +41 -0
  59. package/plugins/extract-java/build/org/portolan/extract/Extract.class +0 -0
  60. package/plugins/extract-java/build/org/portolan/extract/Protocol$Builder.class +0 -0
  61. package/plugins/extract-java/build/org/portolan/extract/Protocol$Input.class +0 -0
  62. package/plugins/extract-java/build/org/portolan/extract/Protocol$Options.class +0 -0
  63. package/plugins/extract-python-kafka/README.md +6 -0
  64. package/plugins/extract-python-kafka/extract.py +2 -4
  65. package/plugins/extract-python-kafka/extract_test.py +18 -2
  66. package/plugins/extract-ts/extract.test.ts +2 -2
  67. package/plugins/extract-ts/extract.ts +4 -5
  68. package/plugins/extract-ts/graphql.test.ts +1 -1
  69. package/plugins/openapi/ids.go +261 -0
  70. package/plugins/openapi/ids_test.go +98 -0
  71. package/plugins/portolan-go.wasm +0 -0
  72. package/plugins/pyplugin/catalog.py +12 -1
  73. package/plugins/pyplugin/kafka.py +74 -3
  74. package/plugins/pyplugin/protocol.py +1 -5
  75. package/portolan.json +3 -2
  76. package/schema/portolan.schema.json +34 -0
  77. package/scripts/README.md +18 -12
  78. package/scripts/catalog-sources.mjs +6 -0
  79. package/scripts/delivery-presets.mjs +21 -11
  80. package/scripts/diff.mjs +5 -1
  81. package/scripts/django-aggregates.test.mjs +58 -0
  82. package/scripts/gen-likec4.mjs +79 -21
  83. package/scripts/gen-likec4.test.mjs +25 -2
  84. package/scripts/gen.mjs +118 -115
  85. package/scripts/go-discovery.test.mjs +30 -0
  86. package/scripts/history.mjs +186 -3
  87. package/scripts/history.test.mjs +1 -1
  88. package/scripts/host-plugins/fetch-git.mjs +77 -21
  89. package/scripts/host-plugins/fetch-git.test.mjs +62 -8
  90. package/scripts/local-api.mjs +71 -4
  91. package/scripts/local-api.test.mjs +63 -4
  92. package/scripts/local-discovery.mjs +82 -9
  93. package/scripts/manifest.mjs +5 -3
  94. package/scripts/manifest.test.mjs +24 -0
  95. package/scripts/output-diff.mjs +94 -0
  96. package/scripts/output-diff.test.mjs +36 -0
  97. package/scripts/package-smoke.mjs +62 -4
  98. package/scripts/plugin-host.mjs +22 -2
  99. package/scripts/plugin-host.test.mjs +9 -0
  100. package/scripts/plugin-wasm-worker.mjs +4 -1
  101. package/scripts/provenance.mjs +72 -0
  102. package/scripts/provenance.test.mjs +149 -0
  103. package/scripts/run-builtin.mjs +39 -5
  104. package/scripts/schema.mjs +29 -0
  105. package/scripts/warning-policy.mjs +161 -0
  106. package/scripts/warning-policy.test.mjs +56 -0
  107. package/src/app/Breadcrumbs.test.ts +4 -0
  108. package/src/app/Breadcrumbs.tsx +1 -0
  109. package/src/app/Sidebar.tsx +3 -3
  110. package/src/catalog-docs.test.ts +64 -0
  111. package/src/catalog-docs.ts +35 -0
  112. package/src/catalog-error.test.ts +15 -0
  113. package/src/catalog-model.ts +70 -6
  114. package/src/catalog-stores.test.ts +17 -0
  115. package/src/catalog-validation.ts +52 -2
  116. package/src/catalog.test.ts +13 -2
  117. package/src/chat/Starter.tsx +5 -11
  118. package/src/chat/tools.test.ts +27 -0
  119. package/src/chat/tools.ts +5 -9
  120. package/src/components/CatalogStamp.tsx +10 -8
  121. package/src/components/ChannelRows.messagepack.test.tsx +28 -0
  122. package/src/components/ChannelRows.test.tsx +54 -0
  123. package/src/components/ChannelRows.tsx +57 -10
  124. package/src/components/HTTPDestinationEvidence.test.tsx +23 -0
  125. package/src/components/HTTPDestinationEvidence.tsx +31 -0
  126. package/src/components/Integrations.tsx +1 -1
  127. package/src/components/LifecycleDiagram.tsx +28 -12
  128. package/src/components/MachineDocs.tsx +6 -5
  129. package/src/components/MethodRows.tsx +9 -2
  130. package/src/components/ProblemRow.tsx +4 -0
  131. package/src/components/RelationEvidence.test.tsx +14 -0
  132. package/src/components/RelationEvidence.tsx +53 -0
  133. package/src/components/WhatLinksHere.tsx +6 -4
  134. package/src/data.ts +25 -7
  135. package/src/enrich.test.ts +336 -6
  136. package/src/enrich.ts +206 -3
  137. package/src/flow/StepDetail.tsx +104 -54
  138. package/src/flow/answers.test.ts +18 -1
  139. package/src/flow/answers.ts +37 -8
  140. package/src/flow/evidence.test.ts +16 -0
  141. package/src/flow/evidence.ts +34 -0
  142. package/src/index.css +44 -0
  143. package/src/landing/DraggableReveal.tsx +3 -2
  144. package/src/landing/EvidencePipeline.tsx +105 -0
  145. package/src/landing/LandingPage.tsx +2 -59
  146. package/src/lib/backlinks.test.ts +16 -1
  147. package/src/lib/backlinks.ts +20 -0
  148. package/src/lib/catalog-diff.test.ts +18 -0
  149. package/src/lib/catalog-diff.ts +20 -2
  150. package/src/lib/derive.ts +1 -0
  151. package/src/lib/django-aggregates.d.mts +9 -0
  152. package/src/lib/django-aggregates.mjs +36 -0
  153. package/src/lib/django-aggregates.test.ts +29 -0
  154. package/src/lib/django-aggregates.ts +5 -0
  155. package/src/lib/kafka-ui.test.ts +87 -0
  156. package/src/lib/kafka-ui.ts +105 -0
  157. package/src/lib/local-api.ts +20 -2
  158. package/src/lib/setup-info.test.ts +17 -0
  159. package/src/lib/setup-info.ts +58 -0
  160. package/src/lib/warnings.test.ts +54 -0
  161. package/src/lib/warnings.ts +260 -0
  162. package/src/lib/wire-problems.test.ts +21 -0
  163. package/src/lib/wire-problems.ts +62 -1
  164. package/src/likec4/FlowView.tsx +2 -6
  165. package/src/likec4/flow-edges.test.ts +64 -1
  166. package/src/likec4/flow-edges.ts +43 -7
  167. package/src/likec4/view-index.ts +8 -2
  168. package/src/map/ContextMapGraph.tsx +76 -32
  169. package/src/merge.test.ts +23 -0
  170. package/src/merge.ts +33 -10
  171. package/src/pages/AggregatePage.tsx +8 -7
  172. package/src/pages/CatalogFailure.tsx +2 -2
  173. package/src/pages/ContextPage.tsx +6 -5
  174. package/src/pages/ServicePage.tsx +4 -3
  175. package/src/pages/Settings.tsx +200 -44
  176. package/src/pages/settings/DjangoAggregateChoices.tsx +79 -0
  177. package/src/pages/settings/IntegrationsSettings.tsx +117 -0
  178. package/src/routes.test.ts +2 -0
  179. package/src/routes.ts +2 -1
  180. package/src/selection/DetailPanel.tsx +61 -1
  181. package/src/virtual-provenance.d.ts +11 -0
  182. package/vite.config.ts +5 -0
  183. package/scripts/vendor-lock.mjs +0 -58
  184. 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
+ }
@@ -22,6 +22,12 @@ id, consumer group, key, headers and serializer facts. Authentication values
22
22
  are never emitted. Partitions, replication and retention are broker-side facts
23
23
  and remain explicitly unknown unless another catalog source declares them.
24
24
 
25
+ MessagePack serializers and deserializers declared through `msgpack` or
26
+ `messagepack` calls are normalized to `encoding: "msgpack"` on the affected
27
+ channel message. This includes constructor callbacks and payloads packed
28
+ directly before a publish; Portolan records the wire format without importing
29
+ or executing the codec.
30
+
25
31
  ```json
26
32
  {
27
33
  "plugin": "python-kafka",
@@ -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,
@@ -99,7 +97,7 @@ def channel(topic: str, sends: List[kafka.Publish], receives: List[kafka.Subscri
99
97
  for item in sends:
100
98
  key = (item.message, "send")
101
99
  if key not in seen:
102
- messages.append(catalog.message(item.message, title(item.message), message_doc(item), "send"))
100
+ messages.append(catalog.message(item.message, title(item.message), message_doc(item), "send", item.encoding))
103
101
  seen.add(key)
104
102
  for item in receives:
105
103
  # Kafka subscriptions dispatch records, and source often does not prove
@@ -108,7 +106,7 @@ def channel(topic: str, sends: List[kafka.Publish], receives: List[kafka.Subscri
108
106
  name = sends[0].message if len({sent.message for sent in sends}) == 1 else "message"
109
107
  key = (name, "receive")
110
108
  if key not in seen:
111
- messages.append(catalog.message(name, title(name), consumer_doc(item), "receive"))
109
+ messages.append(catalog.message(name, title(name), consumer_doc(item), "receive", item.client.encoding))
112
110
  seen.add(key)
113
111
  clients = []
114
112
  for item in list(sends) + list(receives):
@@ -1,5 +1,6 @@
1
1
  """Framework-neutral Kafka extraction contracts."""
2
2
 
3
+ import ast
3
4
  import json
4
5
  import os
5
6
  import sys
@@ -9,6 +10,7 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
9
10
  sys.path.insert(1, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "pyplugin"))
10
11
 
11
12
  from extract import extract # noqa: E402
13
+ import kafka # noqa: E402
12
14
  from options import Options # noqa: E402
13
15
  from protocol import Builder, Input # noqa: E402
14
16
 
@@ -21,7 +23,7 @@ class PythonKafka(unittest.TestCase):
21
23
  fixture = os.path.join(HERE, "testdata", "service")
22
24
  builder = Builder()
23
25
  extract(
24
- Input(root=os.path.relpath(fixture, ROOT), commit="abc1234", generated_at="2026-09-08T00:00:00Z"),
26
+ Input(root=os.path.relpath(fixture, ROOT)),
25
27
  Options.of({"context": "shop", "service": "orders", "settings": "config.settings"}),
26
28
  builder,
27
29
  cwd=ROOT,
@@ -33,7 +35,7 @@ class PythonKafka(unittest.TestCase):
33
35
 
34
36
  def test_three_standard_clients_form_message_channels(self):
35
37
  channels = {item["address"]: item for item in self.service["channels"]}
36
- self.assertEqual(sorted(channels), ["audit.records", "orders.created", "payments.accepted"])
38
+ self.assertEqual(sorted(channels), ["audit.records", "inventory.snapshots", "orders.created", "payments.accepted"])
37
39
  self.assertTrue(all(item["kind"] == "message" for item in channels.values()))
38
40
  self.assertEqual(
39
41
  [message["direction"] for message in channels["orders.created"]["messages"]],
@@ -50,6 +52,20 @@ class PythonKafka(unittest.TestCase):
50
52
  self.assertNotIn("sasl.password", self.contents)
51
53
  self.assertNotIn("sasl.username", self.contents)
52
54
 
55
+ def test_messagepack_serializer_and_deserializer_are_machine_readable(self):
56
+ snapshots = next(item for item in self.service["channels"] if item["address"] == "inventory.snapshots")
57
+ self.assertEqual([message["encoding"] for message in snapshots["messages"]], ["msgpack", "msgpack"])
58
+ self.assertIn("value serializer: lambda value: msgpack.packb(value)", snapshots["doc"])
59
+ self.assertIn("value deserializer: msgpack.unpackb", snapshots["doc"])
60
+
61
+ def test_direct_messagepack_call_keeps_the_payload_name(self):
62
+ call = ast.parse("msgpack.packb(snapshot)", mode="eval").body
63
+ self.assertEqual(kafka.payload_name(call), "snapshot")
64
+ self.assertEqual(kafka.payload_encoding(call), "msgpack")
65
+ packer = ast.parse("msgpack.Packer()", mode="eval").body
66
+ method = ast.parse("packer.pack(snapshot)", mode="eval").body
67
+ self.assertEqual(kafka.payload_encoding(method, {"packer": ("expr", packer)}), "msgpack")
68
+
53
69
  def test_publish_and_receive_flows_have_kafka_handoffs_and_source(self):
54
70
  steps = [step for flow in self.fragment["flows"] for step in flow["steps"]]
55
71
  handoffs = [step["handoff"] for step in steps]
@@ -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, commit: "abc1234", generatedAt: "2026-09-04T00:00:00Z" }, options);
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, commit: "abc1234", generatedAt: "2026-09-04T00:00:00Z" }, options);
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: Catalog = {
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, commit: "abc1234", generatedAt: "2026-09-05T00:00:00Z" }, options);
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
+ }
Binary file
@@ -81,13 +81,24 @@ def channel(address: str, kind: str, title: str, doc: str, messages: List[Dict[s
81
81
  return out
82
82
 
83
83
 
84
- def message(name: str, title: str, doc: str, direction: str) -> Dict[str, Any]:
84
+ def message(
85
+ name: str,
86
+ title: str,
87
+ doc: str,
88
+ direction: str,
89
+ encoding: str = "",
90
+ content_type: str = "",
91
+ ) -> Dict[str, Any]:
85
92
  out: Dict[str, Any] = {"name": name}
86
93
  if title:
87
94
  out["title"] = title
88
95
  if doc:
89
96
  out["doc"] = doc
90
97
  out["direction"] = direction
98
+ if encoding:
99
+ out["encoding"] = encoding
100
+ if content_type:
101
+ out["contentType"] = content_type
91
102
  return out
92
103
 
93
104