@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
@@ -74,7 +74,7 @@ def extract(input_: Input, opts: Options, b: Builder, cwd: str = "") -> None:
74
74
  endpoint_apps = routed_applications(project, applications, route_table)
75
75
  endpoints = []
76
76
  for app in endpoint_apps:
77
- endpoints += [(app, endpoint) for endpoint in transport.read_endpoints(app, b, route_table)]
77
+ endpoints += [(app, endpoint) for endpoint in transport.read_endpoints(app, b, route_table, project)]
78
78
  serializer_registry = serializers_module.read(project, endpoint_apps)
79
79
  auth_registry = auth_module.Registry(project, opts.settings, b)
80
80
 
@@ -169,8 +169,6 @@ def extract(input_: Input, opts: Options, b: Builder, cwd: str = "") -> None:
169
169
  }
170
170
 
171
171
  fragment = {
172
- "generatedAt": input_.generated_at,
173
- "commit": input_.commit,
174
172
  "contexts": [
175
173
  {
176
174
  "id": context,
@@ -223,8 +221,6 @@ def extract(input_: Input, opts: Options, b: Builder, cwd: str = "") -> None:
223
221
  )
224
222
  store_id = "%s.%s" % (svc_id, effective_store)
225
223
  stores_fragment = {
226
- "generatedAt": input_.generated_at,
227
- "commit": input_.commit,
228
224
  "contexts": [
229
225
  {
230
226
  "id": context,
@@ -296,7 +292,7 @@ def http_contracts(endpoints, svc_id: str, source: str) -> List[Dict[str, Any]]:
296
292
  grouped: Dict[str, List[Any]] = {}
297
293
  apps: Dict[str, Any] = {}
298
294
  for app, endpoint in endpoints:
299
- if not endpoint.verb or not endpoint.path:
295
+ if not endpoint.path:
300
296
  continue
301
297
  grouped.setdefault(app.dotted, []).append(endpoint)
302
298
  apps[app.dotted] = app
@@ -313,6 +309,10 @@ def http_contracts(endpoints, svc_id: str, source: str) -> List[Dict[str, Any]]:
313
309
  method = {"name": name}
314
310
  if endpoint.doc:
315
311
  method["doc"] = endpoint.doc
312
+ # A mounted route whose verb no declaration proves keeps its path
313
+ # with the method empty: the route is a fact of the URLConf, the
314
+ # verb is explicitly unknown, and the merge will not match an
315
+ # outbound call against it until somebody declares it.
316
316
  method["http"] = {"method": endpoint.verb, "path": endpoint.path}
317
317
  methods.append(method)
318
318
  if not methods:
@@ -332,7 +332,21 @@ def openapi_document(endpoints, service_name: str, b: Builder, serializer_regist
332
332
  tags = set()
333
333
  operation_ids = set()
334
334
  for app, endpoint in sorted(endpoints, key=lambda item: (item[1].path, item[1].verb, item[0].label, item[1].id)):
335
- if not endpoint.path or not endpoint.verb:
335
+ if not endpoint.path:
336
+ continue
337
+ if not endpoint.verb:
338
+ # The path is mounted; which verb answers there is not written
339
+ # down. A path item without operations says exactly that, where
340
+ # inventing a GET would be read as a fact.
341
+ path_item = paths.setdefault(endpoint.path, {})
342
+ path_item.setdefault("summary", title(endpoint.action))
343
+ path_item.setdefault(
344
+ "description",
345
+ "Mounted in URLConf by %s, but no HTTP verb is declared in source; no operation is inferred." % endpoint.route_source,
346
+ )
347
+ path_item.setdefault("x-portolan-inferred", True)
348
+ path_item.setdefault("x-portolan-source", endpoint.route_source)
349
+ path_item.setdefault("x-portolan-verb", "unknown")
336
350
  continue
337
351
  method = endpoint.verb.lower()
338
352
  path_item = paths.setdefault(endpoint.path, {})
@@ -10,6 +10,7 @@ import ast
10
10
  import json
11
11
  import os
12
12
  import sys
13
+ import tempfile
13
14
  import unittest
14
15
 
15
16
  sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
@@ -35,7 +36,7 @@ OPTIONS = {
35
36
  def run(options):
36
37
  b = Builder()
37
38
  extract(
38
- Input(root=FIXTURE, output=FIXTURE + "/portolan", commit="abc1234", generated_at="2026-09-05T00:00:00Z"),
39
+ Input(root=FIXTURE, output=FIXTURE + "/portolan"),
39
40
  Options.of(options),
40
41
  b,
41
42
  cwd=ROOT,
@@ -193,11 +194,63 @@ class Fragment(unittest.TestCase):
193
194
  self.assertIn("ledger_ledgerentry", tables)
194
195
  self.assertIn("ledger_auditrecord", tables)
195
196
  self.assertNotIn("ledger_ledgerentryproxy", tables)
196
- self.assertNotIn("persists", tables["ledger_ledgerentry"])
197
+ self.assertEqual(tables["ledger_ledgerentry"]["persists"], {"aggregate": "shop.billing.models-ledger", "block": "shop.billing.models-ledger.ledger-entry"})
197
198
  self.assertNotIn("role", tables["ledger_ledgerentry"])
198
199
  service = json.loads(self.files["domain.json"])["contexts"][0]["services"][0]
199
200
  self.assertFalse(any(aggregate["slug"] == "ledger-entry" for aggregate in service["aggregates"]))
200
201
 
202
+ def test_ambiguous_apps_keep_all_concrete_models_without_requiring_a_root(self):
203
+ service = json.loads(self.files["domain.json"])["contexts"][0]["services"][0]
204
+ group = next(a for a in service["aggregates"] if a["slug"] == "models-ledger")
205
+ self.assertEqual(group["kind"], "model-group")
206
+ self.assertEqual(group["root"], "")
207
+ self.assertEqual([m["name"] for m in group["entities"]], ["LedgerEntry", "AuditRecord"])
208
+ self.assertNotIn("lifecycle", group)
209
+ self.assertFalse(any("models to choose from" in w.message or (w.ref == group["id"] and "no services module" in w.message) for w in self.warnings))
210
+
211
+ def test_rootless_group_keeps_app_operations_and_events_once(self):
212
+ with tempfile.TemporaryDirectory() as root:
213
+ app = os.path.join(root, "records")
214
+ os.mkdir(app)
215
+ for name, source in {
216
+ "models.py": "from django.db import models\nclass Alpha(models.Model):\n name = models.CharField(max_length=30)\nclass Beta(models.Model):\n owner = models.ForeignKey(Alpha, on_delete=models.CASCADE)\n",
217
+ "services.py": "def list_records():\n return []\n",
218
+ "events.py": 'from dataclasses import dataclass\n@dataclass\nclass RecordsChanged:\n name = "records.Changed"\n record_id: int\n',
219
+ }.items():
220
+ with open(os.path.join(app, name), "w") as handle:
221
+ handle.write(source)
222
+ builder = Builder()
223
+ extract(Input(root=root), Options.of({"context": "shop", "service": "records"}), builder, cwd=ROOT)
224
+ fragment = json.loads(next(f.contents for f in builder.files if f.name == "domain.json"))
225
+ groups = fragment["contexts"][0]["services"][0]["aggregates"]
226
+ self.assertEqual(len(groups), 1)
227
+ self.assertEqual(groups[0]["kind"], "model-group")
228
+ self.assertEqual(len(groups[0]["entities"]), 2)
229
+ self.assertEqual([op["id"] for op in groups[0]["operations"]], ["ListRecords"])
230
+ self.assertEqual([event["name"] for event in groups[0]["events"]], ["RecordsChanged"])
231
+
232
+ def test_explicit_root_replaces_the_group_without_duplicating_models(self):
233
+ files, warnings = run(dict(OPTIONS, aggregates={"ledger": "LedgerEntry"}))
234
+ self.assertFalse(any("models to choose from" in w.message for w in warnings))
235
+ service = json.loads(files["domain.json"])["contexts"][0]["services"][0]
236
+ aggregate = next(a for a in service["aggregates"] if a["slug"] == "ledger-entry")
237
+ self.assertEqual(aggregate["entities"][0]["name"], "LedgerEntry")
238
+ self.assertNotIn("kind", aggregate)
239
+ self.assertFalse(any(a["slug"] == "models-ledger" for a in service["aggregates"]))
240
+ store = json.loads(files["stores.json"])["stores"][0]
241
+ table = next(t for t in store["tables"] if t["name"] == "ledger_ledgerentry")
242
+ self.assertEqual(table["role"], "aggregate-root")
243
+
244
+ def test_invalid_or_proxy_root_offers_a_replacement(self):
245
+ for root in ["Missing", "LedgerEntryProxy", "RecordBase"]:
246
+ files, warnings = run(dict(OPTIONS, aggregates={"ledger": root}))
247
+ service = json.loads(files["domain.json"])["contexts"][0]["services"][0]
248
+ group = next(a for a in service["aggregates"] if a["slug"] == "models-ledger")
249
+ self.assertEqual(len(group["entities"]), 2)
250
+ warning = next(w for w in warnings if "aggregates names" in w.message)
251
+ candidates = json.loads(warning.message.split("; aggregate candidates: ")[1])
252
+ self.assertEqual([m["name"] for m in candidates["models"]], ["AuditRecord", "LedgerEntry"])
253
+
201
254
  def test_abstract_fields_custom_postgres_fields_and_unresolved_relations_are_described(self):
202
255
  store = json.loads(self.files["stores.json"])["stores"][0]
203
256
  table = next(table for table in store["tables"] if table["name"] == "ledger_ledgerentry")
@@ -160,6 +160,8 @@ def movers(model: ModelDef, status: str, choices: Dict[str, Dict[str, str]], eve
160
160
 
161
161
  def read(agg: Aggregate, events: Dict[str, object], b) -> Optional[Dict[str, object]]:
162
162
  model = agg.root
163
+ if model is None:
164
+ return None
163
165
  status = status_field(model)
164
166
  if status is None:
165
167
  return None
@@ -87,7 +87,7 @@ def read_use_cases(agg: Aggregate, b) -> List[UseCase]:
87
87
  kind="command" if writes(node) else "query",
88
88
  )
89
89
  )
90
- if not out:
90
+ if not out and agg.root is not None:
91
91
  b.warn(agg.id, "no services module under %s: the aggregate has no operations, only whatever the views do inline" % agg.app.rel)
92
92
  return sorted(out, key=lambda u: u.id)
93
93
 
@@ -96,11 +96,70 @@ class Maintenance:
96
96
  @classmethod
97
97
  def fetch(cls, request):
98
98
  return None
99
+
100
+ from django.http import HttpResponseNotAllowed
101
+ from django.utils.decorators import method_decorator
102
+ from django.views.decorators.http import require_GET, require_http_methods
103
+ from rest_framework.decorators import api_view
104
+ from .helpers import ensure_post, guarded
105
+
106
+ class Planet:
107
+ @classmethod
108
+ @require_http_methods(["POST"])
109
+ def fetch(cls, request):
110
+ return None
111
+
112
+ @classmethod
113
+ @ensure_post
114
+ def refresh(cls, request):
115
+ return None
116
+
117
+ @classmethod
118
+ def reindex(cls, request):
119
+ if request.method != "PUT":
120
+ return HttpResponseNotAllowed(["PUT"])
121
+ return None
122
+
123
+ @classmethod
124
+ def status(cls, request):
125
+ return guarded(request, cls._status)
126
+
127
+ @classmethod
128
+ def _status(cls):
129
+ return None
130
+
131
+ @method_decorator(require_GET, name="dispatch")
132
+ class Reports:
133
+ @classmethod
134
+ def summary(cls, request):
135
+ return None
136
+
137
+ class Exports:
138
+ http_method_names = ["patch", "options", "head"]
139
+
140
+ @classmethod
141
+ def run(cls, request):
142
+ return None
143
+
144
+ @api_view(["GET", "POST"])
145
+ def toggle(request):
146
+ return None
147
+ ''',
148
+ "orders/helpers.py": '''
149
+ from django.views.decorators.http import require_POST
150
+
151
+ def ensure_post(view):
152
+ return require_POST(view)
153
+
154
+ def guarded(request, handler):
155
+ if request.method.lower() in ("delete", "patch"):
156
+ return handler()
157
+ return None
99
158
  ''',
100
159
  "orders/urls.py": '''
101
160
  from django.urls import path, re_path
102
161
  from rest_framework.routers import DefaultRouter
103
- from .views import Health, Maintenance, OrderDetail, OrderList, OrderViewSet
162
+ from .views import Exports, Health, Maintenance, OrderDetail, OrderList, OrderViewSet, Planet, Reports, toggle
104
163
 
105
164
  router = DefaultRouter()
106
165
  router.register("orders", OrderViewSet, basename="order")
@@ -109,6 +168,13 @@ urlpatterns = [
109
168
  path("manual/<uuid:pk>/", OrderDetail.as_view(), name="order-detail"),
110
169
  re_path(r"^health/(?P<region>[^/]+)/$", Health.get),
111
170
  path("maintenance/fetch", Maintenance.fetch),
171
+ path("planet/fetch", Planet.fetch),
172
+ path("planet/refresh", Planet.refresh),
173
+ path("planet/reindex", Planet.reindex),
174
+ path("planet/status", Planet.status),
175
+ path("reports/summary", Reports.summary),
176
+ path("exports/run", Exports.run),
177
+ path("toggle/", toggle),
112
178
  ] + router.urls
113
179
  ''',
114
180
  }
@@ -163,6 +229,48 @@ urlpatterns = [
163
229
  parameter = spec["paths"]["/api/v2/manual/{pk}/"]["get"]["parameters"][0]
164
230
  self.assertEqual(parameter["schema"], {"type": "string", "format": "uuid"})
165
231
 
232
+ def test_a_mounted_method_takes_its_verb_from_what_the_code_declares(self):
233
+ routes = routing.read(self.project)
234
+ app = apps.discover(self.project, ["orders"])[0]
235
+ b = Builder()
236
+ endpoints = transport.read_endpoints(app, b, routes, self.project)
237
+ verbs = {}
238
+ for item in endpoints:
239
+ verbs.setdefault(item.path, set()).add(item.verb)
240
+ # Each tier of evidence, from the handler outwards.
241
+ self.assertEqual(verbs["/api/v2/planet/fetch"], {"POST"}) # @require_http_methods on the handler
242
+ self.assertEqual(verbs["/api/v2/reports/summary"], {"GET"}) # @method_decorator(require_GET, name="dispatch") on the class
243
+ self.assertEqual(verbs["/api/v2/exports/run"], {"PATCH"}) # http_method_names, less HEAD and OPTIONS
244
+ self.assertEqual(verbs["/api/v2/planet/reindex"], {"PUT"}) # a branch on request.method
245
+ self.assertEqual(verbs["/api/v2/planet/refresh"], {"POST"}) # a project decorator that applies require_POST
246
+ self.assertEqual(verbs["/api/v2/planet/status"], {"DELETE", "PATCH"}) # a wrapper the handler hands request to
247
+ self.assertEqual(verbs["/api/v2/toggle/"], {"GET", "POST"}) # every method @api_view lists, not the first
248
+ fetch = next(item for item in endpoints if item.path == "/api/v2/planet/fetch")
249
+ self.assertTrue(fetch.verb_source.startswith("decorator at orders/views.py:"), fetch.verb_source)
250
+ status = {item.id: item.verb_source for item in endpoints if item.path == "/api/v2/planet/status"}
251
+ self.assertEqual(sorted(status), ["api_v2_planet_status", "api_v2_planet_status_patch"])
252
+ self.assertTrue(all(source.startswith("wrapper guarded at orders/helpers.py:") for source in status.values()), status)
253
+
254
+ # No tier speaks for Maintenance.fetch: the verb is unknown, and the
255
+ # route stays in the model saying so instead of disappearing.
256
+ self.assertEqual(verbs["/api/v2/maintenance/fetch"], {""})
257
+ self.assertEqual(
258
+ [w.message.split(";")[0] for w in b.warnings if w.ref == "orders/urls.py:12"],
259
+ ["Maintenance.fetch is mounted as an HTTP view, but no HTTP verb is declared"],
260
+ )
261
+ pairs = [(app, item) for item in endpoints]
262
+ contracts = http_contracts(pairs, "shop.orders", "orders/portolan/openapi.inferred.yaml")
263
+ methods = {method["name"]: method["http"] for method in contracts[0]["methods"]}
264
+ self.assertEqual(methods["api_v2_maintenance_fetch"], {"method": "", "path": "/api/v2/maintenance/fetch"})
265
+ self.assertEqual(methods["api_v2_planet_status_patch"], {"method": "PATCH", "path": "/api/v2/planet/status"})
266
+ spec = openapi_document(pairs, "Orders", Builder())
267
+ unknown = spec["paths"]["/api/v2/maintenance/fetch"]
268
+ self.assertEqual(unknown["x-portolan-verb"], "unknown")
269
+ self.assertEqual(unknown["x-portolan-source"], "orders/urls.py:12")
270
+ self.assertFalse({"get", "post", "put", "patch", "delete"} & set(unknown))
271
+ self.assertIn("post", spec["paths"]["/api/v2/planet/fetch"])
272
+ self.assertEqual(sorted(spec["paths"]["/api/v2/planet/status"]), ["delete", "patch"])
273
+
166
274
  def test_queryset_and_serializer_metadata_resolve_the_inherited_action_model(self):
167
275
  routes = routing.read(self.project)
168
276
  app = apps.discover(self.project, ["orders"])[0]
@@ -345,7 +345,7 @@ def model_table(
345
345
  columns,
346
346
  indexes(model, name, by_field),
347
347
  {"aggregate": aggregate.id, "block": block} if aggregate is not None else None,
348
- "aggregate-root" if aggregate is not None and model is aggregate.root else "child" if aggregate is not None else "",
348
+ "aggregate-root" if aggregate is not None and model is aggregate.root else "child" if aggregate is not None and aggregate.root is not None else "",
349
349
  meta_str(model, "verbose_name"),
350
350
  )
351
351
 
@@ -11,12 +11,13 @@ from __future__ import annotations
11
11
  import ast
12
12
  import re
13
13
  from dataclasses import dataclass, field as dc_field
14
- from typing import Dict, List, Optional
14
+ from typing import Dict, List, Optional, Tuple
15
15
 
16
+ import verbs as verbs_module
16
17
  from apps import App
17
18
  from ids import slug
18
19
  from routing import Route, Routes
19
- from source import Module, const_str, doc, dotted, keyword, methods
20
+ from source import Module, Project, const_str, doc, dotted, keyword, methods
20
21
 
21
22
  # The actions a ViewSet has without writing one.
22
23
  ACTIONS = {
@@ -61,6 +62,10 @@ class Endpoint:
61
62
  doc: str = ""
62
63
  use_cases: List[str] = dc_field(default_factory=list)
63
64
  path_parameters: Dict[str, str] = dc_field(default_factory=dict)
65
+ # Where the verb was read when the handler's name did not say it: a
66
+ # decorator, ``http_method_names``, a ``request.method`` branch or a
67
+ # project wrapper, with the file:line. Empty when the verb is unknown.
68
+ verb_source: str = ""
64
69
 
65
70
 
66
71
  def basenames(app: App) -> Dict[str, str]:
@@ -95,18 +100,29 @@ def view_name(name: str) -> str:
95
100
  return slug(name).replace("-", "_")
96
101
 
97
102
 
98
- def verb_of(node: ast.AST, action: str) -> str:
99
- decorator = None
103
+ HANDLER_NAMES = ("get", "post", "put", "patch", "delete")
104
+
105
+
106
+ def verbs_of(node: ast.AST, action: str) -> Tuple[str, ...]:
107
+ """The verbs a handler answers by its own declaration: every method an
108
+ ``@action``/``@api_view`` lists, else what its conventional name means.
109
+ Empty for a handler whose name is the project's own."""
100
110
  for dec in getattr(node, "decorator_list", []):
101
111
  if isinstance(dec, ast.Call) and dotted(dec.func).split(".")[-1] in ("action", "api_view"):
102
- decorator = dec
103
- if decorator is not None:
104
- methods_arg = keyword(decorator, "methods")
105
- if methods_arg is None and decorator.args:
106
- methods_arg = decorator.args[0]
107
- if isinstance(methods_arg, (ast.List, ast.Tuple, ast.Set)) and methods_arg.elts:
108
- return const_str(methods_arg.elts[0]).upper()
109
- return ACTIONS.get(action, "" if action not in ("get", "post", "put", "patch", "delete") else action.upper())
112
+ listed = verbs_module.listed_verbs(dec)
113
+ if listed:
114
+ return listed
115
+ if action in ACTIONS:
116
+ return (ACTIONS[action],)
117
+ return (action.upper(),) if action in HANDLER_NAMES else ()
118
+
119
+
120
+ def is_action(node: ast.AST) -> bool:
121
+ return any(isinstance(dec, ast.Call) and dotted(dec.func).split(".")[-1] == "action" for dec in getattr(node, "decorator_list", []))
122
+
123
+
124
+ def is_api_view(node: ast.AST) -> bool:
125
+ return any(isinstance(dec, ast.Call) and dotted(dec.func).split(".")[-1] == "api_view" for dec in getattr(node, "decorator_list", []))
110
126
 
111
127
 
112
128
  def route_base(route: Route, fallback: str) -> str:
@@ -134,8 +150,10 @@ def action_path(route: Route, node: ast.AST, action: str) -> str:
134
150
  return base + ("/{id}" if detail else "") + ("/" + suffix if action not in ACTIONS else "") + "/"
135
151
 
136
152
 
137
- def endpoint(node: ast.AST, module: Module, view: str, action: str, verb: str, base: str, route: Optional[Route], description: str = "") -> Endpoint:
153
+ def endpoint(node: ast.AST, module: Module, view: str, action: str, verb: str, base: str, route: Optional[Route], description: str = "", suffix: str = "", verb_source: str = "") -> Endpoint:
138
154
  ident = base if base == action or base.endswith("_" + action) else "%s_%s" % (base, action)
155
+ if suffix:
156
+ ident = "%s_%s" % (ident, suffix)
139
157
  return Endpoint(
140
158
  id=ident,
141
159
  action=action,
@@ -147,11 +165,30 @@ def endpoint(node: ast.AST, module: Module, view: str, action: str, verb: str, b
147
165
  route_source=route.source if route else "",
148
166
  doc=description,
149
167
  path_parameters=dict(route.parameters) if route else {},
168
+ verb_source=verb_source,
150
169
  )
151
170
 
152
171
 
153
- def read_endpoints(app: App, b, routes: Optional[Routes] = None) -> List[Endpoint]:
172
+ def unknown_verb(b, route: Route) -> None:
173
+ b.warn(
174
+ route.source,
175
+ "%s is mounted as an HTTP view, but no HTTP verb is declared; the route is kept with its verb unknown and no operation is inferred for it. "
176
+ "Declare the verb with require_http_methods, @api_view, http_method_names or a branch on request.method" % route.view,
177
+ )
178
+
179
+
180
+ def expand(handler: ast.AST, module: Module, view: str, action: str, verb_list: Tuple[str, ...], base: str, route: Optional[Route], description: str, verb_source: str) -> List[Endpoint]:
181
+ """One endpoint per verb. The first keeps the plain id; a second verb on
182
+ the same handler is told apart by the verb, ``planet_fetch_post``."""
183
+ out = []
184
+ for index, verb in enumerate(verb_list):
185
+ out.append(endpoint(handler, module, view, action, verb, base, route, description, suffix=verb.lower() if index else "", verb_source=verb_source))
186
+ return out
187
+
188
+
189
+ def read_endpoints(app: App, b, routes: Optional[Routes] = None, project: Optional[Project] = None) -> List[Endpoint]:
154
190
  registered = basenames(app)
191
+ reader = verbs_module.Reader(project)
155
192
  out: List[Endpoint] = []
156
193
  for module in app.package("views"):
157
194
  for node in module.classes():
@@ -165,62 +202,71 @@ def read_endpoints(app: App, b, routes: Optional[Routes] = None) -> List[Endpoin
165
202
  base = view_name(node.name)
166
203
  b.warn(module.rel, "%s is registered by no router in %s/urls.py; its endpoints are named after the class" % (node.name, app.rel))
167
204
  handlers = {handler.name: handler for handler in methods(node)}
168
- declared = []
205
+ method_routes: Dict[str, List[Route]] = {}
206
+ for route in mounted:
207
+ if "." in route.view:
208
+ method_routes.setdefault(route.view.split(".", 1)[1], []).append(route)
209
+ # (action, verbs, handler, doc, where the verb was read)
210
+ declared: List[Tuple[str, Tuple[str, ...], ast.AST, str, str]] = []
169
211
  for handler in handlers.values():
170
212
  if handler.name.startswith("_"):
171
213
  continue
172
- is_action = any(
173
- isinstance(dec, ast.Call) and dotted(dec.func).split(".")[-1] == "action" for dec in getattr(handler, "decorator_list", [])
174
- )
175
- if handler.name not in ACTIONS and not is_action and handler.name not in ("get", "post", "put", "patch", "delete"):
176
- # A plain Django class may expose an arbitrarily named
177
- # method directly in URLConf (`Planet.fetch`). The route
178
- # proves the HTTP entrypoint but, without a method
179
- # decorator or request-method branch, not one particular
180
- # verb. Keep it as a flow root and leave it out of the
181
- # inferred OpenAPI contract.
182
- if handler.name not in {
183
- route.view.split(".", 1)[1]
184
- for route in mounted
185
- if "." in route.view
186
- }:
187
- continue
188
- declared.append((handler.name, verb_of(handler, handler.name), handler, doc(handler)))
214
+ verb_list = verbs_of(handler, handler.name)
215
+ verb_source = ""
216
+ if not verb_list and not is_action(handler) and handler.name not in method_routes:
217
+ continue
218
+ if not verb_list:
219
+ # A plain class may expose an arbitrarily named method
220
+ # directly in URLConf (`Planet.fetch`). The route proves
221
+ # the HTTP entrypoint; the verb is read off what the
222
+ # handler, its class or a project wrapper declares, and
223
+ # is left unknown - never guessed - when none of them does.
224
+ evidence = reader.for_handler(module, handler, node)
225
+ if evidence is not None:
226
+ verb_list, verb_source = evidence.verbs, "%s at %s" % (evidence.rule, evidence.source)
227
+ else:
228
+ verb_list = ("",)
229
+ declared.append((handler.name, verb_list, handler, doc(handler), verb_source))
189
230
  inherited = []
190
231
  for inherited_base in bases:
191
232
  inherited += GENERIC_ACTIONS.get(inherited_base, [])
192
233
  for action, verb in inherited:
193
- same_direct_handler = mounted and all(not route.router for route in mounted) and any(item[1] == verb for item in declared)
234
+ same_direct_handler = mounted and all(not route.router for route in mounted) and any(verb in item[1] for item in declared)
194
235
  if action not in handlers and not any(item[0] == action for item in declared) and not same_direct_handler:
195
- declared.append((action, verb, node, doc(node)))
236
+ declared.append((action, (verb,), node, doc(node), ""))
196
237
  class_routes = routes.for_view(module.dotted, node.name) if routes else []
197
- method_routes = {route.view.split(".", 1)[1]: route for route in mounted if "." in route.view}
198
238
  if not class_routes and not method_routes:
199
- for action, verb, handler, description in declared:
200
- out.append(endpoint(handler, module, node.name, action, verb, base or view_name(node.name), None, description))
239
+ for action, verb_list, handler, description, verb_source in declared:
240
+ out += expand(handler, module, node.name, action, verb_list, base or view_name(node.name), None, description, verb_source)
201
241
  continue
202
- for action, verb, handler, description in declared:
203
- targets = class_routes or ([method_routes[action]] if action in method_routes else [])
204
- for route in targets:
205
- if not verb:
206
- b.warn(
207
- route.source,
208
- "%s is mounted as an HTTP view, but no HTTP verb is declared; its flow is extracted and the route is omitted from inferred OpenAPI"
209
- % route.view,
210
- )
211
- out.append(endpoint(handler, module, node.name, action, verb, route_base(route, base or view_name(node.name)), route, description))
242
+ for action, verb_list, handler, description, verb_source in declared:
243
+ for route in class_routes or method_routes.get(action, []):
244
+ if "" in verb_list:
245
+ unknown_verb(b, route)
246
+ out += expand(handler, module, node.name, action, verb_list, route_base(route, base or view_name(node.name)), route, description, verb_source)
212
247
  for node in module.functions():
213
248
  mounted = routes.for_view(module.dotted, node.name) if routes else []
214
- if not mounted and not any(isinstance(dec, ast.Call) and dotted(dec.func).split(".")[-1] == "api_view" for dec in getattr(node, "decorator_list", [])):
249
+ if not mounted and not is_api_view(node):
215
250
  continue
216
- verb = verb_of(node, node.name)
217
- if not verb:
251
+ verb_list = verbs_of(node, node.name)
252
+ verb_source = ""
253
+ if not verb_list:
254
+ evidence = reader.for_handler(module, node)
255
+ if evidence is not None:
256
+ verb_list, verb_source = evidence.verbs, "%s at %s" % (evidence.rule, evidence.source)
257
+ elif mounted:
258
+ verb_list = ("",)
259
+ else:
260
+ continue
261
+ if not mounted:
262
+ for verb in verb_list:
263
+ out.append(Endpoint(id=node.name, action=node.name, view="", verb=verb, node=node, module=module, doc=doc(node), verb_source=verb_source))
218
264
  continue
219
- if mounted:
220
- for route in mounted:
221
- out.append(endpoint(node, module, "", verb.lower(), verb, route_base(route, node.name), route, doc(node)))
222
- else:
223
- out.append(Endpoint(id=node.name, action=node.name, view="", verb=verb, node=node, module=module, doc=doc(node)))
265
+ for route in mounted:
266
+ if "" in verb_list:
267
+ unknown_verb(b, route)
268
+ for verb in verb_list:
269
+ out.append(endpoint(node, module, "", verb.lower() or node.name, verb, route_base(route, node.name), route, doc(node), verb_source=verb_source))
224
270
  unique = {}
225
271
  for found in out:
226
272
  unique[(found.id, found.verb, found.path, found.module.rel)] = found