@shortlink-org/portolan 0.1.0 → 0.2.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 +69 -205
- package/cli/init.mjs +285 -0
- package/cli/init.test.mjs +158 -0
- package/cli/portolan.mjs +37 -73
- package/index.html +19 -0
- package/package.json +5 -10
- package/plugins/README.md +66 -27
- package/plugins/portolan-go.wasm +0 -0
- package/portolan.json +52 -73
- package/public/landing/cats/bug-hunter.webp +0 -0
- package/public/landing/cats/compass-nap.webp +0 -0
- package/public/landing/cats/diagrammer.webp +0 -0
- package/public/landing/cats/docs-reader.webp +0 -0
- package/public/landing/cats/laptop.webp +0 -0
- package/public/landing/cats/map-inspector.webp +0 -0
- package/public/landing/cats/server-break.webp +0 -0
- package/public/landing/cats/star-mapper.webp +0 -0
- package/public/landing/cats/system-builder.webp +0 -0
- package/public/landing/cats/thread-tangle.webp +0 -0
- package/public/og.png +0 -0
- package/public/readme-header.webp +0 -0
- package/schema/portolan.schema.json +180 -0
- package/scripts/builtin-plugins.mjs +14 -0
- package/scripts/delivery-presets.mjs +353 -0
- package/scripts/gen.mjs +37 -4
- package/scripts/history.mjs +126 -0
- package/scripts/history.test.mjs +110 -0
- package/scripts/host-plugins/fetch-bsr.mjs +351 -0
- package/{plugins/fetch-bsr/options.schema.json → scripts/host-plugins/fetch-bsr.options.json} +4 -0
- package/scripts/host-plugins/fetch-bsr.test.mjs +212 -0
- package/scripts/host-plugins/fetch-csr.mjs +386 -0
- package/scripts/host-plugins/fetch-csr.test.mjs +178 -0
- package/scripts/host-plugins/fetch-git.mjs +379 -0
- package/scripts/host-plugins/fetch-git.test.mjs +210 -0
- package/scripts/local-api.mjs +22 -4
- package/scripts/local-api.test.mjs +84 -2
- package/scripts/manifest.mjs +3 -2
- package/scripts/package-smoke.mjs +6 -2
- package/scripts/plugin-host.mjs +77 -18
- package/scripts/plugin-host.test.mjs +50 -1
- package/scripts/plugin-wasm-worker.mjs +6 -3
- package/scripts/run-builtin.mjs +6 -26
- package/scripts/schema.mjs +6 -1
- package/src/app/App.tsx +24 -429
- package/src/app/Breadcrumbs.test.ts +7 -0
- package/src/app/Breadcrumbs.tsx +15 -2
- package/src/app/CatalogApp.tsx +436 -0
- package/src/app/Sidebar.tsx +0 -1
- package/src/chat/ChatPanel.tsx +98 -41
- package/src/chat/Composer.tsx +4 -2
- package/src/chat/Conversation.tsx +79 -15
- package/src/chat/Header.tsx +43 -9
- package/src/chat/Starter.tsx +64 -3
- package/src/chat/Steps.tsx +1 -1
- package/src/chat/flags.test.ts +51 -14
- package/src/chat/flags.ts +8 -2
- package/src/chat/page-context.test.ts +51 -0
- package/src/chat/page-context.ts +128 -0
- package/src/chat/prompt.test.ts +49 -1
- package/src/chat/prompt.ts +42 -1
- package/src/chat/tools.ts +7 -2
- package/src/chat/transport.ts +16 -4
- package/src/components/ProblemRow.tsx +182 -0
- package/src/data.ts +20 -5
- package/src/er/ErCanvas.tsx +1 -1
- package/src/graph/DependencyGraph.tsx +10 -1
- package/src/graph/FocusedEventGraph.tsx +1 -1
- package/src/graph/GraphToolbar.tsx +2 -4
- package/src/index.css +220 -2
- package/src/landing/DraggableReveal.tsx +168 -0
- package/src/landing/EstateGraph.tsx +28 -0
- package/src/landing/FlowPlayback.tsx +282 -0
- package/src/landing/HeroMap.tsx +128 -0
- package/src/landing/LandingPage.tsx +757 -0
- package/src/landing/ProductFrame.tsx +68 -0
- package/src/landing/ProductTour.tsx +371 -0
- package/src/landing/catalog.ts +25 -0
- package/src/landing/motion.tsx +52 -0
- package/src/lib/all-problems.ts +28 -0
- package/src/lib/local-api.ts +35 -1
- package/src/lib/setup-info.test.ts +8 -0
- package/src/lib/setup-info.ts +3 -2
- package/src/lib/source-code.ts +2 -1
- package/src/likec4/C4View.tsx +5 -0
- package/src/likec4/InteractiveView.tsx +5 -1
- package/src/map/ContextMapGraph.tsx +47 -20
- package/src/pages/ContextMap.tsx +5 -1
- package/src/pages/FlowDetail.tsx +24 -7
- package/src/pages/Overview.tsx +68 -12
- package/src/pages/Problems.tsx +6 -193
- package/src/pages/Settings.tsx +133 -108
- package/src/pages/settings/DeliverySettings.tsx +229 -0
- package/src/pages/settings/PreferencesSettings.tsx +101 -0
- package/src/routes.test.ts +20 -0
- package/src/routes.ts +13 -1
- package/vite.config.ts +12 -8
- package/catalog/enum_test.go +0 -46
- package/catalog/model.go +0 -932
- package/catalog/roundtrip_test.go +0 -185
- package/catalog/via_test.go +0 -38
- package/go.mod +0 -14
- package/go.sum +0 -14
- package/internal/commands/cargo.go +0 -154
- package/internal/commands/commands.go +0 -143
- package/internal/commands/commands_test.go +0 -323
- package/internal/commands/gradle.go +0 -77
- package/internal/commands/justfile.go +0 -82
- package/internal/commands/makefile.go +0 -106
- package/internal/commands/maven.go +0 -93
- package/internal/commands/packagejson.go +0 -159
- package/internal/commands/pyproject.go +0 -169
- package/internal/commands/taskfile.go +0 -101
- package/internal/commands/testdata/estate/.cargo/config.toml +0 -7
- package/internal/commands/testdata/estate/Makefile +0 -33
- package/internal/commands/testdata/estate/Taskfile.yml +0 -21
- package/internal/commands/testdata/estate/build.gradle.kts +0 -21
- package/internal/commands/testdata/estate/justfile +0 -20
- package/internal/commands/testdata/estate/package.json +0 -13
- package/internal/commands/testdata/estate/pom.xml +0 -29
- package/internal/commands/testdata/estate/pyproject.toml +0 -27
- package/internal/commands/testdata/estate/xtask/src/main.rs +0 -27
- package/internal/commands/testdata/golden/commands.json +0 -305
- package/internal/gohttp/analyze.go +0 -2500
- package/internal/gohttp/endpoints.go +0 -1067
- package/internal/gohttp/roots.go +0 -320
- package/internal/gohttp/typed.go +0 -143
- package/internal/goscan/constants.go +0 -85
- package/internal/goscan/goscan_test.go +0 -227
- package/internal/goscan/names.go +0 -52
- package/internal/goscan/parse_test.go +0 -11
- package/internal/goscan/source.go +0 -36
- package/internal/goscan/tree.go +0 -155
- package/internal/goscan/types.go +0 -99
- package/internal/wsdl/ids.go +0 -127
- package/internal/wsdl/ids_test.go +0 -21
- package/internal/wsdl/model.go +0 -62
- package/internal/wsdl/parse.go +0 -920
- package/internal/wsdl/parse_test.go +0 -133
- package/plugin/describe.go +0 -107
- package/plugin/describe_test.go +0 -114
- package/plugin/protocol.go +0 -117
- package/plugin/schematest/schematest.go +0 -126
- package/plugins/extract-adr/describe.go +0 -19
- package/plugins/extract-adr/describe_test.go +0 -11
- package/plugins/extract-adr/extract.go +0 -153
- package/plugins/extract-adr/extract_test.go +0 -350
- package/plugins/extract-adr/history.go +0 -99
- package/plugins/extract-adr/main.go +0 -65
- package/plugins/extract-adr/parse.go +0 -642
- package/plugins/extract-adr/parse_test.go +0 -405
- package/plugins/extract-asyncapi/describe.go +0 -19
- package/plugins/extract-asyncapi/describe_test.go +0 -11
- package/plugins/extract-asyncapi/extract.go +0 -315
- package/plugins/extract-asyncapi/extract_test.go +0 -197
- package/plugins/extract-asyncapi/main.go +0 -48
- package/plugins/extract-asyncapi/spec.go +0 -151
- package/plugins/extract-commands/describe.go +0 -19
- package/plugins/extract-commands/describe_test.go +0 -11
- package/plugins/extract-commands/extract.go +0 -79
- package/plugins/extract-commands/extract_test.go +0 -92
- package/plugins/extract-commands/main.go +0 -55
- package/plugins/extract-csr/avro.go +0 -258
- package/plugins/extract-csr/describe.go +0 -19
- package/plugins/extract-csr/describe_test.go +0 -11
- package/plugins/extract-csr/extract.go +0 -338
- package/plugins/extract-csr/extract_test.go +0 -374
- package/plugins/extract-csr/jsonschema.go +0 -343
- package/plugins/extract-csr/lock.go +0 -25
- package/plugins/extract-csr/main.go +0 -84
- package/plugins/extract-csr/subject.go +0 -102
- package/plugins/extract-flows/describe.go +0 -19
- package/plugins/extract-flows/describe_test.go +0 -11
- package/plugins/extract-flows/extract.go +0 -91
- package/plugins/extract-flows/main.go +0 -45
- package/plugins/extract-flows/parse.go +0 -593
- package/plugins/extract-flows/parse_test.go +0 -204
- package/plugins/extract-glossary/describe.go +0 -19
- package/plugins/extract-glossary/describe_test.go +0 -11
- package/plugins/extract-glossary/extract.go +0 -115
- package/plugins/extract-glossary/extract_test.go +0 -220
- package/plugins/extract-glossary/main.go +0 -59
- package/plugins/extract-glossary/parse.go +0 -214
- package/plugins/extract-glossary/parse_test.go +0 -203
- package/plugins/extract-go/aggregate.go +0 -214
- package/plugins/extract-go/client.go +0 -409
- package/plugins/extract-go/client_test.go +0 -266
- package/plugins/extract-go/describe.go +0 -19
- package/plugins/extract-go/describe_test.go +0 -11
- package/plugins/extract-go/enum.go +0 -195
- package/plugins/extract-go/enum_test.go +0 -82
- package/plugins/extract-go/event.go +0 -99
- package/plugins/extract-go/extract.go +0 -191
- package/plugins/extract-go/extract_test.go +0 -261
- package/plugins/extract-go/flow.go +0 -1441
- package/plugins/extract-go/flow_test.go +0 -609
- package/plugins/extract-go/httpclient.go +0 -174
- package/plugins/extract-go/httpclient_test.go +0 -296
- package/plugins/extract-go/ids.go +0 -92
- package/plugins/extract-go/layout.go +0 -225
- package/plugins/extract-go/layout_test.go +0 -125
- package/plugins/extract-go/lifecycle.go +0 -324
- package/plugins/extract-go/lifecycle_test.go +0 -108
- package/plugins/extract-go/main.go +0 -79
- package/plugins/extract-go/operation.go +0 -157
- package/plugins/extract-go/source.go +0 -316
- package/plugins/extract-go/transport.go +0 -321
- package/plugins/extract-go/transport_test.go +0 -145
- package/plugins/extract-go/wiring.go +0 -434
- package/plugins/extract-go-nats/describe.go +0 -19
- package/plugins/extract-go-nats/describe_test.go +0 -11
- package/plugins/extract-go-nats/extract.go +0 -177
- package/plugins/extract-go-nats/extract_test.go +0 -389
- package/plugins/extract-go-nats/index.go +0 -379
- package/plugins/extract-go-nats/main.go +0 -42
- package/plugins/extract-go-nats/resolve.go +0 -161
- package/plugins/extract-go-nats/sites.go +0 -224
- package/plugins/extract-graphql/describe.go +0 -19
- package/plugins/extract-graphql/describe_test.go +0 -11
- package/plugins/extract-graphql/extract.go +0 -433
- package/plugins/extract-graphql/extract_test.go +0 -256
- package/plugins/extract-graphql/ids.go +0 -49
- package/plugins/extract-graphql/lex.go +0 -237
- package/plugins/extract-graphql/main.go +0 -51
- package/plugins/extract-graphql/parse.go +0 -621
- package/plugins/extract-graphql/parse_test.go +0 -122
- package/plugins/extract-http-clients/describe.go +0 -19
- package/plugins/extract-http-clients/describe_test.go +0 -11
- package/plugins/extract-http-clients/extract.go +0 -705
- package/plugins/extract-http-clients/extract_test.go +0 -1263
- package/plugins/extract-http-clients/main.go +0 -42
- package/plugins/extract-openapi/describe.go +0 -19
- package/plugins/extract-openapi/describe_test.go +0 -11
- package/plugins/extract-openapi/discover.go +0 -243
- package/plugins/extract-openapi/extract.go +0 -526
- package/plugins/extract-openapi/extract_test.go +0 -545
- package/plugins/extract-openapi/main.go +0 -75
- package/plugins/extract-openapi/spec.go +0 -350
- package/plugins/extract-project/describe.go +0 -19
- package/plugins/extract-project/describe_test.go +0 -11
- package/plugins/extract-project/extract.go +0 -221
- package/plugins/extract-project/extract_test.go +0 -109
- package/plugins/extract-project/main.go +0 -41
- package/plugins/extract-proto/ast.go +0 -125
- package/plugins/extract-proto/consumes.go +0 -77
- package/plugins/extract-proto/describe.go +0 -19
- package/plugins/extract-proto/extract.go +0 -293
- package/plugins/extract-proto/extract_test.go +0 -459
- package/plugins/extract-proto/ids.go +0 -89
- package/plugins/extract-proto/ids_test.go +0 -57
- package/plugins/extract-proto/lex.go +0 -285
- package/plugins/extract-proto/main.go +0 -100
- package/plugins/extract-proto/module.go +0 -120
- package/plugins/extract-proto/parse.go +0 -720
- package/plugins/extract-proto/parse_test.go +0 -307
- package/plugins/extract-proto/provides.go +0 -236
- package/plugins/extract-proto/resolve.go +0 -222
- package/plugins/extract-proto/resolve_test.go +0 -119
- package/plugins/extract-redis/describe.go +0 -19
- package/plugins/extract-redis/describe_test.go +0 -11
- package/plugins/extract-redis/extract.go +0 -183
- package/plugins/extract-redis/extract_test.go +0 -168
- package/plugins/extract-redis/keyspaces.go +0 -469
- package/plugins/extract-redis/main.go +0 -44
- package/plugins/extract-river/describe.go +0 -19
- package/plugins/extract-river/describe_test.go +0 -11
- package/plugins/extract-river/extract.go +0 -507
- package/plugins/extract-river/extract_test.go +0 -132
- package/plugins/extract-river/main.go +0 -42
- package/plugins/extract-sql/ddl.go +0 -893
- package/plugins/extract-sql/ddl_test.go +0 -401
- package/plugins/extract-sql/describe.go +0 -19
- package/plugins/extract-sql/describe_test.go +0 -11
- package/plugins/extract-sql/layout.go +0 -221
- package/plugins/extract-sql/layout_test.go +0 -55
- package/plugins/extract-sql/lineage.go +0 -107
- package/plugins/extract-sql/main.go +0 -141
- package/plugins/extract-sql/maps.go +0 -564
- package/plugins/extract-sql/maps_java.go +0 -117
- package/plugins/extract-sql/maps_rust.go +0 -333
- package/plugins/extract-sql/maps_rust_test.go +0 -70
- package/plugins/extract-sql/maps_test.go +0 -204
- package/plugins/extract-sql/maps_ts.go +0 -398
- package/plugins/extract-sql/maps_ts_test.go +0 -136
- package/plugins/extract-sql/projection.go +0 -70
- package/plugins/extract-sql/projection_test.go +0 -50
- package/plugins/extract-sql/store.go +0 -420
- package/plugins/extract-sql/store_test.go +0 -233
- package/plugins/extract-sql/view.go +0 -295
- package/plugins/extract-watermill/describe.go +0 -19
- package/plugins/extract-watermill/describe_test.go +0 -11
- package/plugins/extract-watermill/extract.go +0 -1228
- package/plugins/extract-watermill/extract_test.go +0 -234
- package/plugins/extract-watermill/main.go +0 -41
- package/plugins/extract-wsdl/describe.go +0 -19
- package/plugins/extract-wsdl/describe_test.go +0 -11
- package/plugins/extract-wsdl/extract.go +0 -148
- package/plugins/extract-wsdl/extract_test.go +0 -45
- package/plugins/extract-wsdl/main.go +0 -53
- package/plugins/fetch-bsr/auth.go +0 -114
- package/plugins/fetch-bsr/bsr.go +0 -240
- package/plugins/fetch-bsr/cache.go +0 -66
- package/plugins/fetch-bsr/describe.go +0 -19
- package/plugins/fetch-bsr/fetch.go +0 -192
- package/plugins/fetch-bsr/fetch_test.go +0 -465
- package/plugins/fetch-bsr/lock.go +0 -71
- package/plugins/fetch-bsr/main.go +0 -117
- package/plugins/fetch-csr/auth.go +0 -95
- package/plugins/fetch-csr/cache.go +0 -69
- package/plugins/fetch-csr/describe.go +0 -19
- package/plugins/fetch-csr/describe_test.go +0 -11
- package/plugins/fetch-csr/fetch.go +0 -254
- package/plugins/fetch-csr/fetch_test.go +0 -494
- package/plugins/fetch-csr/lock.go +0 -95
- package/plugins/fetch-csr/main.go +0 -122
- package/plugins/fetch-csr/registry.go +0 -209
- package/plugins/fetch-git/cache.go +0 -65
- package/plugins/fetch-git/describe.go +0 -19
- package/plugins/fetch-git/describe_test.go +0 -11
- package/plugins/fetch-git/fetch.go +0 -181
- package/plugins/fetch-git/fetch_test.go +0 -332
- package/plugins/fetch-git/git.go +0 -169
- package/plugins/fetch-git/lock.go +0 -72
- package/plugins/fetch-git/main.go +0 -127
- package/plugins/fetch-git/offline.go +0 -39
- package/plugins/fetch-git/pin.go +0 -90
- package/plugins/fetch-git/pin_test.go +0 -74
- package/plugins/gen-backstage/describe.go +0 -17
- package/plugins/gen-backstage/main.go +0 -22
- package/plugins/gen-backstage/plugin.go +0 -473
- package/plugins/gen-backstage/plugin_test.go +0 -145
- package/plugins/gen-backstage.wasm +0 -0
- package/plugins/gen-markdown/adr.go +0 -162
- package/plugins/gen-markdown/aggregate.go +0 -362
- package/plugins/gen-markdown/canonical.go +0 -93
- package/plugins/gen-markdown/context.go +0 -90
- package/plugins/gen-markdown/coverage_test.go +0 -89
- package/plugins/gen-markdown/describe.go +0 -19
- package/plugins/gen-markdown/describe_test.go +0 -11
- package/plugins/gen-markdown/external.go +0 -71
- package/plugins/gen-markdown/flow.go +0 -277
- package/plugins/gen-markdown/glossary.go +0 -76
- package/plugins/gen-markdown/glossary_test.go +0 -148
- package/plugins/gen-markdown/llms.go +0 -302
- package/plugins/gen-markdown/main.go +0 -22
- package/plugins/gen-markdown/markdown.go +0 -254
- package/plugins/gen-markdown/markdown_test.go +0 -100
- package/plugins/gen-markdown/module.go +0 -107
- package/plugins/gen-markdown/plugin.go +0 -39
- package/plugins/gen-markdown/quality_test.go +0 -176
- package/plugins/gen-markdown/redis_test.go +0 -38
- package/plugins/gen-markdown/render.go +0 -344
- package/plugins/gen-markdown/render_test.go +0 -186
- package/plugins/gen-markdown/service.go +0 -456
- package/plugins/gen-markdown/source.go +0 -177
- package/plugins/gen-markdown/store.go +0 -201
- package/plugins/gen-markdown.wasm +0 -0
- package/plugins/gen-mermaid/describe.go +0 -17
- package/plugins/gen-mermaid/main.go +0 -22
- package/plugins/gen-mermaid/plugin.go +0 -104
- package/plugins/gen-mermaid/plugin_test.go +0 -33
- package/plugins/gen-mermaid.wasm +0 -0
- package/plugins/openapi/ids.go +0 -261
- package/plugins/openapi/ids_test.go +0 -98
- package/plugins/verify-codeowners/describe.go +0 -19
- package/plugins/verify-codeowners/describe_test.go +0 -11
- package/plugins/verify-codeowners/main.go +0 -75
- package/plugins/verify-codeowners/match.go +0 -85
- package/plugins/verify-codeowners/match_test.go +0 -47
- package/plugins/verify-codeowners/owners.go +0 -164
- package/plugins/verify-codeowners/owners_test.go +0 -225
- package/plugins/verify-codeowners/parse.go +0 -90
- package/plugins/verify-codeowners/parse_test.go +0 -62
- package/plugins/verify-otel/describe.go +0 -19
- package/plugins/verify-otel/describe_test.go +0 -11
- package/plugins/verify-otel/main.go +0 -53
- package/plugins/verify-otel/match.go +0 -336
- package/plugins/verify-otel/otlp.go +0 -200
- package/plugins/verify-otel/verify.go +0 -734
- package/plugins/verify-otel/verify_test.go +0 -460
- /package/{plugins/fetch-csr/options.schema.json → scripts/host-plugins/fetch-csr.options.json} +0 -0
- /package/{plugins/fetch-git/options.schema.json → scripts/host-plugins/fetch-git.options.json} +0 -0
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
// fetch-csr, run by the host: a Confluent Schema Registry on one side, schema
|
|
2
|
+
// files committed to this repository on the other.
|
|
3
|
+
//
|
|
4
|
+
// Shaped like fetch-bsr on purpose and living by the same rules: a pinned
|
|
5
|
+
// version is immutable, so a fetch is byte-reproducible; the committed copy
|
|
6
|
+
// replays offline and in CI; a failed fetch falls back to the tree and, with
|
|
7
|
+
// nothing to fall back to, is an error rather than a short file list. A
|
|
8
|
+
// subject a fetched schema references is fetched too, pinned by the reference
|
|
9
|
+
// itself, so following one adds no lottery.
|
|
10
|
+
//
|
|
11
|
+
// It runs in the host (portolan.0008) because it needs a socket and a
|
|
12
|
+
// credential. The credential comes from the environment and is never in the
|
|
13
|
+
// manifest; the registry URL is a fact about the estate and is.
|
|
14
|
+
|
|
15
|
+
import { createHash } from "node:crypto";
|
|
16
|
+
import { readFileSync } from "node:fs";
|
|
17
|
+
import { join, posix } from "node:path";
|
|
18
|
+
|
|
19
|
+
import optionsSchema from "./fetch-csr.options.json" with { type: "json" };
|
|
20
|
+
|
|
21
|
+
export const LOCK_NAME = "csr.lock.json";
|
|
22
|
+
export const OFFLINE_ENV = "PORTOLAN_OFFLINE";
|
|
23
|
+
export const KEY_ENV = "CONFLUENT_SCHEMA_REGISTRY_API_KEY";
|
|
24
|
+
export const SECRET_ENV = "CONFLUENT_SCHEMA_REGISTRY_API_SECRET";
|
|
25
|
+
export const SHORT_KEY_ENV = "CSR_API_KEY";
|
|
26
|
+
export const SHORT_SECRET_ENV = "CSR_API_SECRET";
|
|
27
|
+
export const TOKEN_ENV = "CSR_TOKEN";
|
|
28
|
+
|
|
29
|
+
// The registry's own media type first and plain JSON second, so the same
|
|
30
|
+
// client works against Confluent's registry and the API-compatible ones.
|
|
31
|
+
const ACCEPT = "application/vnd.schemaregistry.v1+json, application/json";
|
|
32
|
+
|
|
33
|
+
export function describe() {
|
|
34
|
+
return {
|
|
35
|
+
name: "fetch-csr",
|
|
36
|
+
summary: "Fetches pinned subject versions from a Confluent Schema Registry into the tree, with a lock beside each, so a schema published to a registry can be read without talking to it.",
|
|
37
|
+
phases: ["extract"],
|
|
38
|
+
options: optionsSchema,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* @param {{options?: object}} request
|
|
44
|
+
* @param {{env?: NodeJS.ProcessEnv, fetch?: typeof fetch}} [io]
|
|
45
|
+
*/
|
|
46
|
+
export async function run(request, { env = process.env, fetch: fetchFn = globalThis.fetch } = {}) {
|
|
47
|
+
const options = request.options ?? {};
|
|
48
|
+
const registry = String(options.registry ?? "").trim();
|
|
49
|
+
if (!registry) throw new Error("no registry: name the schema registry's base URL in the manifest");
|
|
50
|
+
const subjects = Array.isArray(options.subjects) ? options.subjects : [];
|
|
51
|
+
if (subjects.length === 0) throw new Error("no subjects to fetch: name at least one in the manifest");
|
|
52
|
+
if (!options.cache) {
|
|
53
|
+
throw new Error("no cache directory: set `cache` to the same path as the step's `out`, so an offline run can replay what the last online one wrote");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const out = new Builder();
|
|
57
|
+
const queue = [...subjects].map((want) => ({ subject: String(want.subject ?? ""), version: Number(want.version ?? 0) || 0 }))
|
|
58
|
+
.sort((a, b) => (a.subject < b.subject ? -1 : a.subject > b.subject ? 1 : 0));
|
|
59
|
+
const client = new Client(registry, authorization(env), fetchFn);
|
|
60
|
+
const skip = offline(env);
|
|
61
|
+
|
|
62
|
+
// Two subjects that slug to the same directory would overwrite each other,
|
|
63
|
+
// and one subject wanted at two versions cannot be held in a tree that
|
|
64
|
+
// keeps one version per subject. Both are refused by name.
|
|
65
|
+
const claimed = new Map();
|
|
66
|
+
const done = new Set();
|
|
67
|
+
|
|
68
|
+
while (queue.length) {
|
|
69
|
+
const want = queue.shift();
|
|
70
|
+
if (!want.subject) throw new Error("a subject entry names no subject");
|
|
71
|
+
if (claimed.has(want.subject)) {
|
|
72
|
+
const held = claimed.get(want.subject);
|
|
73
|
+
if (held.version !== want.version) {
|
|
74
|
+
throw new Error(`${want.subject} is wanted at version ${pinOf(held.version)} and at version ${pinOf(want.version)}; the tree keeps one version per subject, so pin one`);
|
|
75
|
+
}
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
const dir = slugOf(want.subject);
|
|
79
|
+
if (done.has(dir)) throw new Error(`${want.subject} and another subject both vendor into ${dir}; the tree cannot hold both`);
|
|
80
|
+
claimed.set(want.subject, want);
|
|
81
|
+
done.add(dir);
|
|
82
|
+
const at = join(options.cache, dir);
|
|
83
|
+
|
|
84
|
+
let references;
|
|
85
|
+
if (skip) {
|
|
86
|
+
references = emitCached(out, registry, dir, at, want, "offline");
|
|
87
|
+
} else {
|
|
88
|
+
try {
|
|
89
|
+
references = await live(client, out, registry, dir, want);
|
|
90
|
+
} catch (cause) {
|
|
91
|
+
try {
|
|
92
|
+
references = emitCached(out, registry, dir, at, want, cause.message);
|
|
93
|
+
} catch (cacheCause) {
|
|
94
|
+
throw new Error(`${want.subject} could not be fetched (${cause.message}) and there is no usable copy in the tree (${cacheCause.message})`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// A referenced subject is pinned by the schema that references it, so
|
|
100
|
+
// following one needs no permission from the manifest.
|
|
101
|
+
const following = references
|
|
102
|
+
.filter((ref) => ref.subject && !claimed.has(ref.subject))
|
|
103
|
+
.map((ref) => ({ subject: ref.subject, version: Number(ref.version) || 0 }))
|
|
104
|
+
.sort((a, b) => (a.subject < b.subject ? -1 : a.subject > b.subject ? 1 : 0));
|
|
105
|
+
queue.push(...following);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return out.response();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function offline(env = process.env) {
|
|
112
|
+
if (String(env[OFFLINE_ENV] ?? "").trim()) return true;
|
|
113
|
+
return !["", "0", "false"].includes(String(env.CI ?? "").trim().toLowerCase());
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** The Authorization value, or "" for an anonymous registry. */
|
|
117
|
+
export function authorization(env = process.env) {
|
|
118
|
+
const read = (name) => String(env[name] ?? "").trim();
|
|
119
|
+
const raw = read(TOKEN_ENV);
|
|
120
|
+
if (raw) {
|
|
121
|
+
// Passed through when it already names its scheme, so the same variable
|
|
122
|
+
// serves "Bearer ..." and an opaque token a proxy expects bare.
|
|
123
|
+
return raw.includes(" ") ? raw : `Bearer ${raw}`;
|
|
124
|
+
}
|
|
125
|
+
const key = read(KEY_ENV) || read(SHORT_KEY_ENV);
|
|
126
|
+
const secret = read(SECRET_ENV) || read(SHORT_SECRET_ENV);
|
|
127
|
+
if (!key || !secret) return "";
|
|
128
|
+
return `Basic ${Buffer.from(`${key}:${secret}`).toString("base64")}`;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* The directory a subject vendors into. A subject name is whatever the
|
|
133
|
+
* producer registered; everything outside a safe set becomes an underscore,
|
|
134
|
+
* and the true subject is written in the lock beside the file.
|
|
135
|
+
*/
|
|
136
|
+
export function slugOf(subject) {
|
|
137
|
+
const slug = [...String(subject)].map((char) => (/[A-Za-z0-9._-]/.test(char) ? char : "_")).join("").replace(/^\.+|\.+$/g, "");
|
|
138
|
+
return slug || "_";
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** The empty answer is AVRO by the registry's own rule. */
|
|
142
|
+
export function schemaType(declared) {
|
|
143
|
+
const clean = String(declared ?? "").trim();
|
|
144
|
+
return clean ? clean.toUpperCase() : "AVRO";
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function extensionFor(kind) {
|
|
148
|
+
switch (schemaType(kind)) {
|
|
149
|
+
case "AVRO": return ".avsc";
|
|
150
|
+
case "PROTOBUF": return ".proto";
|
|
151
|
+
case "JSON": return ".json";
|
|
152
|
+
default: throw new Error(`unknown schema type "${kind}"`);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* The schema as it should be written. AVRO and JSON arrive minified onto one
|
|
158
|
+
* line; they are indented without reordering or rewriting a single token, so
|
|
159
|
+
* the file still says what the registry said. PROTOBUF is left as it is.
|
|
160
|
+
*/
|
|
161
|
+
export function body(kind, schema) {
|
|
162
|
+
if (schemaType(kind) === "PROTOBUF") return withNewline(schema);
|
|
163
|
+
try {
|
|
164
|
+
JSON.parse(schema);
|
|
165
|
+
} catch {
|
|
166
|
+
// A registry that answered with something that is not JSON has told us
|
|
167
|
+
// something, and the honest thing is to keep it so the reader can see.
|
|
168
|
+
return withNewline(schema);
|
|
169
|
+
}
|
|
170
|
+
return withNewline(indentJson(schema));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** json.Indent: re-spaced, never re-parsed - every token stays as it came. */
|
|
174
|
+
export function indentJson(text) {
|
|
175
|
+
let out = "";
|
|
176
|
+
let depth = 0;
|
|
177
|
+
let index = 0;
|
|
178
|
+
const skipSpace = () => { while (index < text.length && /\s/.test(text[index])) index += 1; };
|
|
179
|
+
while (index < text.length) {
|
|
180
|
+
const char = text[index];
|
|
181
|
+
if (/\s/.test(char)) { index += 1; continue; }
|
|
182
|
+
if (char === '"') {
|
|
183
|
+
let end = index + 1;
|
|
184
|
+
while (end < text.length && text[end] !== '"') end += text[end] === "\\" ? 2 : 1;
|
|
185
|
+
out += text.slice(index, end + 1);
|
|
186
|
+
index = end + 1;
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
if (char === "{" || char === "[") {
|
|
190
|
+
const closer = char === "{" ? "}" : "]";
|
|
191
|
+
index += 1;
|
|
192
|
+
skipSpace();
|
|
193
|
+
if (text[index] === closer) { out += char + closer; index += 1; continue; }
|
|
194
|
+
depth += 1;
|
|
195
|
+
out += `${char}\n${" ".repeat(depth)}`;
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
if (char === "}" || char === "]") {
|
|
199
|
+
depth -= 1;
|
|
200
|
+
out += `\n${" ".repeat(depth)}${char}`;
|
|
201
|
+
index += 1;
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
if (char === ",") { out += `,\n${" ".repeat(depth)}`; index += 1; continue; }
|
|
205
|
+
if (char === ":") { out += ": "; index += 1; continue; }
|
|
206
|
+
let end = index;
|
|
207
|
+
while (end < text.length && !/[\s,\]}:]/.test(text[end])) end += 1;
|
|
208
|
+
out += text.slice(index, end);
|
|
209
|
+
index = end;
|
|
210
|
+
}
|
|
211
|
+
return out;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function withNewline(text) {
|
|
215
|
+
return text.endsWith("\n") ? text : `${text}\n`;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** The lock, one subject per directory, references sorted by name. */
|
|
219
|
+
export function encodeLock(registry, entry) {
|
|
220
|
+
const record = { subject: entry.subject, version: entry.version, id: entry.id };
|
|
221
|
+
if (entry.guid) record.guid = entry.guid;
|
|
222
|
+
record.schemaType = entry.schemaType;
|
|
223
|
+
if (entry.references?.length) {
|
|
224
|
+
record.references = [...entry.references]
|
|
225
|
+
.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
|
|
226
|
+
.map((ref) => ({ name: ref.name, subject: ref.subject, version: ref.version }));
|
|
227
|
+
}
|
|
228
|
+
record.files = [...entry.files]
|
|
229
|
+
.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0))
|
|
230
|
+
.map((file) => ({ path: file.path, sha256: file.sha256, size: file.size }));
|
|
231
|
+
return `${JSON.stringify({ registry, subjects: [record] }, null, 2)}\n`;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function digestOf(contents) {
|
|
235
|
+
return createHash("sha256").update(contents).digest("hex");
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function pinOf(version) {
|
|
239
|
+
return version === 0 ? "latest" : String(version);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
async function live(client, out, registry, dir, want) {
|
|
243
|
+
const got = await client.version(want.subject, want.version);
|
|
244
|
+
if (want.version === 0) {
|
|
245
|
+
out.warn(want.subject, `is not pinned; "latest" resolved to version ${got.version}. Pin it in portolan.json or every run is a lottery.`);
|
|
246
|
+
}
|
|
247
|
+
let extension;
|
|
248
|
+
try {
|
|
249
|
+
extension = extensionFor(got.schemaType);
|
|
250
|
+
} catch (cause) {
|
|
251
|
+
throw new Error(`${want.subject} at version ${got.version}: ${cause.message}`);
|
|
252
|
+
}
|
|
253
|
+
const name = `v${got.version}${extension}`;
|
|
254
|
+
const contents = body(got.schemaType, got.schema);
|
|
255
|
+
const entry = {
|
|
256
|
+
subject: want.subject,
|
|
257
|
+
version: got.version,
|
|
258
|
+
id: got.id ?? 0,
|
|
259
|
+
guid: got.guid ?? "",
|
|
260
|
+
schemaType: schemaType(got.schemaType),
|
|
261
|
+
references: got.references ?? [],
|
|
262
|
+
files: [{ path: name, sha256: digestOf(contents), size: Buffer.byteLength(contents) }],
|
|
263
|
+
};
|
|
264
|
+
out.file(posix.join(dir, name), contents);
|
|
265
|
+
out.file(posix.join(dir, LOCK_NAME), encodeLock(registry, entry));
|
|
266
|
+
return entry.references;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function emitCached(out, registry, dir, at, want, why) {
|
|
270
|
+
if (want.version === 0) throw new Error(`${want.subject} is not pinned to a version, so there is nothing to replay`);
|
|
271
|
+
const held = replay(at);
|
|
272
|
+
if (held.lock.version !== want.version) throw new Error(`${want.subject} holds version ${held.lock.version} but the manifest pins ${want.version}; fetch it`);
|
|
273
|
+
if (held.lock.subject !== want.subject) throw new Error(`${want.subject} vendors into ${at.split("\\").join("/")}, which holds ${held.lock.subject}`);
|
|
274
|
+
if (held.registry && held.registry !== registry) throw new Error(`${want.subject} was fetched from ${held.registry} but the manifest names ${registry}; fetch it again`);
|
|
275
|
+
for (const file of held.lock.files) out.file(posix.join(dir, file.path), held.files.get(file.path).toString("utf8"));
|
|
276
|
+
out.file(posix.join(dir, LOCK_NAME), encodeLock(registry, held.lock));
|
|
277
|
+
out.warn(want.subject, `not fetched (${why}); the copy committed in this repository is used unchanged`);
|
|
278
|
+
return held.lock.references ?? [];
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function replay(dir) {
|
|
282
|
+
const lockPath = join(dir, LOCK_NAME);
|
|
283
|
+
let raw;
|
|
284
|
+
try {
|
|
285
|
+
raw = readFileSync(lockPath, "utf8");
|
|
286
|
+
} catch (cause) {
|
|
287
|
+
if (cause.code === "ENOENT") throw new Error(`no ${LOCK_NAME} in ${dir.split("\\").join("/")}`);
|
|
288
|
+
throw cause;
|
|
289
|
+
}
|
|
290
|
+
let lock;
|
|
291
|
+
try {
|
|
292
|
+
lock = JSON.parse(raw);
|
|
293
|
+
} catch (cause) {
|
|
294
|
+
throw new Error(`${lockPath}: ${cause.message}`);
|
|
295
|
+
}
|
|
296
|
+
const subjects = Array.isArray(lock.subjects) ? lock.subjects : [];
|
|
297
|
+
if (subjects.length !== 1) throw new Error(`${lockPath} names ${subjects.length} subjects; expected exactly one`);
|
|
298
|
+
const entry = subjects[0];
|
|
299
|
+
const files = new Map();
|
|
300
|
+
for (const want of entry.files ?? []) {
|
|
301
|
+
let contents;
|
|
302
|
+
try {
|
|
303
|
+
contents = readFileSync(join(dir, ...String(want.path).split("/")));
|
|
304
|
+
} catch (cause) {
|
|
305
|
+
if (cause.code === "ENOENT") throw new Error(`${want.path} is in the lock but not on disk`);
|
|
306
|
+
throw cause;
|
|
307
|
+
}
|
|
308
|
+
if (digestOf(contents) !== want.sha256) throw new Error(`${want.path} does not match its digest; the vendored copy was edited by hand`);
|
|
309
|
+
files.set(want.path, contents);
|
|
310
|
+
}
|
|
311
|
+
return {
|
|
312
|
+
registry: String(lock.registry ?? ""),
|
|
313
|
+
lock: {
|
|
314
|
+
subject: entry.subject, version: Number(entry.version) || 0, id: entry.id ?? 0, guid: entry.guid ?? "",
|
|
315
|
+
schemaType: entry.schemaType ?? "", references: entry.references ?? [], files: entry.files ?? [],
|
|
316
|
+
},
|
|
317
|
+
files,
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** The registry's REST API, and only the one call this plugin makes. */
|
|
322
|
+
class Client {
|
|
323
|
+
constructor(base, auth, fetchFn) {
|
|
324
|
+
this.base = base.replace(/\/+$/, "");
|
|
325
|
+
this.auth = auth;
|
|
326
|
+
this.fetch = fetchFn;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** One registration. A zero version asks for `latest`. */
|
|
330
|
+
async version(subject, version) {
|
|
331
|
+
const which = version > 0 ? String(version) : "latest";
|
|
332
|
+
const at = `${this.base}/subjects/${encodeURIComponent(subject)}/versions/${which}`;
|
|
333
|
+
let got;
|
|
334
|
+
try {
|
|
335
|
+
got = await this.get(at);
|
|
336
|
+
} catch (cause) {
|
|
337
|
+
throw new Error(`${subject} at version ${which}: ${cause.message}`);
|
|
338
|
+
}
|
|
339
|
+
if (!String(got.schema ?? "").trim()) throw new Error(`${subject} at version ${which}: the registry answered with no schema`);
|
|
340
|
+
if (!(Number(got.version) > 0)) throw new Error(`${subject} at version ${which}: the registry did not say which version it answered with`);
|
|
341
|
+
return { ...got, version: Number(got.version) };
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
async get(at) {
|
|
345
|
+
const headers = { Accept: ACCEPT };
|
|
346
|
+
if (this.auth) headers.Authorization = this.auth;
|
|
347
|
+
let response;
|
|
348
|
+
try {
|
|
349
|
+
response = await this.fetch(at, { headers, signal: AbortSignal.timeout(30_000) });
|
|
350
|
+
} catch (cause) {
|
|
351
|
+
throw new Error(cause.cause?.message ?? cause.message);
|
|
352
|
+
}
|
|
353
|
+
const raw = await response.text();
|
|
354
|
+
if (!response.ok) {
|
|
355
|
+
try {
|
|
356
|
+
const fault = JSON.parse(raw);
|
|
357
|
+
if (fault?.message) throw new Error(`the registry answered ${response.status} ${response.statusText}: ${fault.message} (error_code ${fault.error_code ?? 0})`);
|
|
358
|
+
} catch (cause) {
|
|
359
|
+
if (cause.message.startsWith("the registry answered")) throw cause;
|
|
360
|
+
}
|
|
361
|
+
throw new Error(`the registry answered ${response.status} ${response.statusText}`);
|
|
362
|
+
}
|
|
363
|
+
try {
|
|
364
|
+
return JSON.parse(raw);
|
|
365
|
+
} catch (cause) {
|
|
366
|
+
throw new Error(`the registry's answer is not the shape this plugin reads: ${cause.message}`);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
class Builder {
|
|
372
|
+
files = [];
|
|
373
|
+
warnings = [];
|
|
374
|
+
|
|
375
|
+
file(name, contents) {
|
|
376
|
+
this.files.push({ name, contents });
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
warn(ref, message) {
|
|
380
|
+
this.warnings.push({ severity: "warning", message, ref });
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
response() {
|
|
384
|
+
return { files: this.files, warnings: this.warnings };
|
|
385
|
+
}
|
|
386
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// The fetcher, against a recorded registry. NEVER against a live one.
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { createServer } from "node:http";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { dirname, join } from "node:path";
|
|
7
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
8
|
+
|
|
9
|
+
import { runPlugin } from "../plugin-host.mjs";
|
|
10
|
+
import { KEY_ENV, OFFLINE_ENV, SECRET_ENV, TOKEN_ENV, authorization, body, indentJson, run, slugOf } from "./fetch-csr.mjs";
|
|
11
|
+
|
|
12
|
+
const VALUE = "shop.oms.order-value";
|
|
13
|
+
const PARTY = "shop.oms.Party";
|
|
14
|
+
// The value schema references the party one, which is how a registry says a
|
|
15
|
+
// record is shared between subjects.
|
|
16
|
+
const ORDER = '{"type":"record","namespace":"shop.oms","name":"OrderPlaced","fields":[{"name":"order_id","type":"string"},{"name":"buyer","type":"shop.oms.Party"}]}';
|
|
17
|
+
const PARTY_SCHEMA = '{"type":"record","namespace":"shop.oms","name":"Party","fields":[{"name":"id","type":"string"}]}';
|
|
18
|
+
|
|
19
|
+
const cleanups = [];
|
|
20
|
+
afterEach(async () => {
|
|
21
|
+
for (const cleanup of cleanups.splice(0)) await cleanup();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
async function registry(answers = recorded()) {
|
|
25
|
+
const seen = [];
|
|
26
|
+
const server = createServer((request, response) => {
|
|
27
|
+
seen.push(request.headers.authorization ?? "");
|
|
28
|
+
response.setHeader("Content-Type", "application/json");
|
|
29
|
+
const answer = answers[decodeURIComponent(request.url)];
|
|
30
|
+
if (!answer) {
|
|
31
|
+
response.statusCode = 404;
|
|
32
|
+
response.end(JSON.stringify({ error_code: 40401, message: `Subject '${request.url}' not found.` }));
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
if (typeof answer === "function") return answer(response);
|
|
36
|
+
response.end(JSON.stringify(answer));
|
|
37
|
+
});
|
|
38
|
+
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
39
|
+
cleanups.push(() => new Promise((resolve) => server.close(resolve)));
|
|
40
|
+
return { url: `http://127.0.0.1:${server.address().port}`, seen };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function recorded() {
|
|
44
|
+
const order = { subject: VALUE, version: 3, id: 100021, guid: "8f0d", schemaType: "AVRO", schema: ORDER, references: [{ name: "shop.oms.Party", subject: PARTY, version: 1 }] };
|
|
45
|
+
return {
|
|
46
|
+
"/subjects/shop.oms.order-value/versions/3": order,
|
|
47
|
+
"/subjects/shop.oms.order-value/versions/latest": order,
|
|
48
|
+
"/subjects/shop.oms.Party/versions/1": { subject: PARTY, version: 1, id: 100014, schemaType: "AVRO", schema: PARTY_SCHEMA },
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function cache() {
|
|
53
|
+
const dir = mkdtempSync(join(tmpdir(), "portolan-fetch-csr-"));
|
|
54
|
+
cleanups.push(() => rmSync(dir, { recursive: true, force: true }));
|
|
55
|
+
return dir;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const isolated = { [OFFLINE_ENV]: "", CI: "", [TOKEN_ENV]: "", [KEY_ENV]: "", [SECRET_ENV]: "", CSR_API_KEY: "", CSR_API_SECRET: "" };
|
|
59
|
+
const options = (base, cacheDir, ...subjects) => ({ registry: base, cache: cacheDir, subjects });
|
|
60
|
+
const fetch = (opts, env = {}) => run({ options: opts }, { env: { ...isolated, ...env } });
|
|
61
|
+
const names = (response) => response.files.map((file) => file.name).sort();
|
|
62
|
+
const contentsOf = (response, name) => response.files.find((file) => file.name === name)?.contents ?? "";
|
|
63
|
+
const write = (dir, response) => {
|
|
64
|
+
for (const file of response.files) {
|
|
65
|
+
mkdirSync(dirname(join(dir, file.name)), { recursive: true });
|
|
66
|
+
writeFileSync(join(dir, file.name), file.contents);
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
describe("fetch-csr", () => {
|
|
71
|
+
it("writes the schema, its lock, and follows a reference the schema pinned", async () => {
|
|
72
|
+
const server = await registry();
|
|
73
|
+
const response = await fetch(options(server.url, cache(), { subject: VALUE, version: 3 }));
|
|
74
|
+
expect(names(response)).toEqual(["shop.oms.Party/csr.lock.json", "shop.oms.Party/v1.avsc", "shop.oms.order-value/csr.lock.json", "shop.oms.order-value/v3.avsc"]);
|
|
75
|
+
expect(contentsOf(response, "shop.oms.Party/v1.avsc")).toContain('"name": "Party"');
|
|
76
|
+
|
|
77
|
+
// Indented rather than the one line the registry answers with, without
|
|
78
|
+
// reordering a key.
|
|
79
|
+
const schema = contentsOf(response, "shop.oms.order-value/v3.avsc");
|
|
80
|
+
expect(schema).toContain('\n "type": "record"');
|
|
81
|
+
expect(schema.endsWith("\n")).toBe(true);
|
|
82
|
+
expect(schema.indexOf('"namespace"')).toBeLessThan(schema.indexOf('"name"'));
|
|
83
|
+
expect(JSON.parse(schema)).toEqual(JSON.parse(ORDER));
|
|
84
|
+
|
|
85
|
+
const lock = JSON.parse(contentsOf(response, "shop.oms.order-value/csr.lock.json"));
|
|
86
|
+
expect(lock).toEqual({
|
|
87
|
+
registry: server.url,
|
|
88
|
+
subjects: [{
|
|
89
|
+
subject: VALUE, version: 3, id: 100021, guid: "8f0d", schemaType: "AVRO",
|
|
90
|
+
references: [{ name: "shop.oms.Party", subject: PARTY, version: 1 }],
|
|
91
|
+
files: [{ path: "v3.avsc", sha256: createHash("sha256").update(schema).digest("hex"), size: schema.length }],
|
|
92
|
+
}],
|
|
93
|
+
});
|
|
94
|
+
expect(JSON.parse(contentsOf(response, "shop.oms.Party/csr.lock.json")).subjects[0]).not.toHaveProperty("guid");
|
|
95
|
+
expect(response.warnings).toEqual([]);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("reads an absent schema type as AVRO and resolves latest with a warning", async () => {
|
|
99
|
+
const answers = recorded();
|
|
100
|
+
delete answers["/subjects/shop.oms.Party/versions/1"].schemaType;
|
|
101
|
+
const server = await registry(answers);
|
|
102
|
+
const response = await fetch(options(server.url, cache(), { subject: VALUE }));
|
|
103
|
+
expect(JSON.parse(contentsOf(response, "shop.oms.Party/csr.lock.json")).subjects[0].schemaType).toBe("AVRO");
|
|
104
|
+
expect(response.warnings).toHaveLength(1);
|
|
105
|
+
expect(response.warnings[0].message).toContain('"latest" resolved to version 3');
|
|
106
|
+
|
|
107
|
+
await expect(fetch(options("http://127.0.0.1:1", cache(), { subject: VALUE }), { [OFFLINE_ENV]: "1" })).rejects.toThrow(/not pinned/);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("replays the committed copies offline, references included, byte for byte", async () => {
|
|
111
|
+
const server = await registry();
|
|
112
|
+
const cacheDir = cache();
|
|
113
|
+
const online = await fetch(options(server.url, cacheDir, { subject: VALUE, version: 3 }));
|
|
114
|
+
write(cacheDir, online);
|
|
115
|
+
const replayed = await fetch(options(server.url, cacheDir, { subject: VALUE, version: 3 }), { [OFFLINE_ENV]: "1" });
|
|
116
|
+
expect(names(replayed)).toEqual(names(online));
|
|
117
|
+
for (const file of online.files) expect(contentsOf(replayed, file.name)).toBe(file.contents);
|
|
118
|
+
expect(replayed.warnings.map((warning) => warning.ref)).toEqual([VALUE, PARTY]);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("falls back to the tree when the registry fails, and is an error without one", async () => {
|
|
122
|
+
const server = await registry();
|
|
123
|
+
const cacheDir = cache();
|
|
124
|
+
write(cacheDir, await fetch(options(server.url, cacheDir, { subject: VALUE, version: 3 })));
|
|
125
|
+
const broken = await registry({ "/subjects/shop.oms.order-value/versions/3": (response) => { response.statusCode = 502; response.end(); } });
|
|
126
|
+
// The lock names the registry it came from; a copy from another one is
|
|
127
|
+
// not this one's to replay.
|
|
128
|
+
await expect(fetch(options(broken.url, cacheDir, { subject: VALUE, version: 3 }))).rejects.toThrow(/was fetched from .* but the manifest names/);
|
|
129
|
+
|
|
130
|
+
await expect(fetch(options("http://127.0.0.1:1", cache(), { subject: VALUE, version: 3 }))).rejects.toThrow(/no usable copy/);
|
|
131
|
+
const missing = await registry({});
|
|
132
|
+
await expect(fetch(options(missing.url, cache(), { subject: VALUE, version: 3 }))).rejects.toThrow(/Subject .* not found\. \(error_code 40401\)/);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("reports an edited copy, a moved pin, and one subject at two versions", async () => {
|
|
136
|
+
const server = await registry();
|
|
137
|
+
const cacheDir = cache();
|
|
138
|
+
write(cacheDir, await fetch(options(server.url, cacheDir, { subject: VALUE, version: 3 })));
|
|
139
|
+
await expect(fetch(options(server.url, cacheDir, { subject: VALUE, version: 2 }), { [OFFLINE_ENV]: "1" })).rejects.toThrow(/holds version 3 but the manifest pins 2/);
|
|
140
|
+
writeFileSync(join(cacheDir, "shop.oms.order-value/v3.avsc"), "{}\n");
|
|
141
|
+
await expect(fetch(options(server.url, cacheDir, { subject: VALUE, version: 3 }), { [OFFLINE_ENV]: "1" })).rejects.toThrow(/v3\.avsc does not match its digest/);
|
|
142
|
+
await expect(fetch(options(server.url, cache(), { subject: VALUE, version: 3 }, { subject: VALUE, version: 2 }))).rejects.toThrow(/one version per subject/);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("sends the credential from the environment and gives a bare token a scheme", async () => {
|
|
146
|
+
const server = await registry();
|
|
147
|
+
await fetch(options(server.url, cache(), { subject: PARTY, version: 1 }));
|
|
148
|
+
await fetch(options(server.url, cache(), { subject: PARTY, version: 1 }), { [KEY_ENV]: "key", [SECRET_ENV]: "secret" });
|
|
149
|
+
expect(server.seen).toEqual(["", `Basic ${Buffer.from("key:secret").toString("base64")}`]);
|
|
150
|
+
expect(authorization({ [TOKEN_ENV]: "abc" })).toBe("Bearer abc");
|
|
151
|
+
expect(authorization({ [TOKEN_ENV]: "Token abc" })).toBe("Token abc");
|
|
152
|
+
expect(authorization({ CSR_API_KEY: "k", CSR_API_SECRET: "s" })).toBe(`Basic ${Buffer.from("k:s").toString("base64")}`);
|
|
153
|
+
expect(authorization({ CSR_API_KEY: "k" })).toBe("");
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("runs through the host like any plugin", async () => {
|
|
157
|
+
const server = await registry();
|
|
158
|
+
const result = await runPlugin({ name: "csr", host: "fetch-csr" }, { portolanVersion: "0.1.0", options: options(server.url, cache(), { subject: PARTY, version: 1 }) });
|
|
159
|
+
expect(result.files.map((file) => file.name)).toEqual(["shop.oms.Party/v1.avsc", "shop.oms.Party/csr.lock.json"]);
|
|
160
|
+
const described = await runPlugin({ name: "csr", host: "fetch-csr" }, { portolanVersion: "0.1.0", kind: "describe" });
|
|
161
|
+
expect(described.describe).toMatchObject({ name: "fetch-csr", phases: ["extract"] });
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
describe("shapes", () => {
|
|
166
|
+
it("keeps a subject readable as a directory", () => {
|
|
167
|
+
expect(slugOf("shop.oms.order-value")).toBe("shop.oms.order-value");
|
|
168
|
+
expect(slugOf("shop.oms.OrderPlaced")).toBe("shop.oms.OrderPlaced");
|
|
169
|
+
expect(slugOf("a/b c")).toBe("a_b_c");
|
|
170
|
+
expect(slugOf("...")).toBe("_");
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("indents JSON without touching a token, and leaves protobuf alone", () => {
|
|
174
|
+
expect(indentJson('{"a":[1,2.50,"x\\"y",{"b":null}],"c":{},"d":[],"e":true}')).toBe('{\n "a": [\n 1,\n 2.50,\n "x\\"y",\n {\n "b": null\n }\n ],\n "c": {},\n "d": [],\n "e": true\n}');
|
|
175
|
+
expect(body("PROTOBUF", 'syntax = "proto3";')).toBe('syntax = "proto3";\n');
|
|
176
|
+
expect(body("JSON", "not json")).toBe("not json\n");
|
|
177
|
+
});
|
|
178
|
+
});
|