@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
|
@@ -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.
|
|
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
|
|
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"
|
|
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.
|
|
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
|
-
|
|
99
|
-
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
173
|
-
|
|
174
|
-
)
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
#
|
|
178
|
-
#
|
|
179
|
-
#
|
|
180
|
-
#
|
|
181
|
-
#
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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]
|
|
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,
|
|
200
|
-
out
|
|
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,
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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
|
|
249
|
+
if not mounted and not is_api_view(node):
|
|
215
250
|
continue
|
|
216
|
-
|
|
217
|
-
|
|
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
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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
|