openvibers/openvibe.sources Commits

Sources: api paths join across the response ($root, {$key}, *) and filter records (where); AWS Lightsail seed (#39)

OP OpenVibers committed 12 Oct 2026 · 61322adab7 · 9 files, +592 −64

The `api` adapter can now read AWS's Lightsail price list, giving openvibe.host its second provider.

- **`*` path segment:** steps into an object's single child, for the unknown offer and price-dimension keys.
- **`$root.` paths:** read from the response root; a `{$key}` segment stands for the record's map key. One mapping joins each SKU to its price: `$root.terms.OnDemand.{$key}.*.priceDimensions.*.pricePerUnit.USD`.
- **`where`:** up to 10 path → value conditions (a value or a list of values). Records that fail are left out before `max_items` counts, and are reported as `meta.filtered`.
- `checkMapping` rejects malformed uses: `{$key}` outside `$root.`, a bare `$root`, `$key` inside a longer path, object values, and more than 10 conditions or 20 values.
- The registry stores `where` only when given, so existing records stay byte-identical.
- **New seed `aws-lightsail-bundles`:** disabled, review required, with robots, terms and licence notes. It parses the real us-east-1 file into 50 Linux bundles (100 filtered out), each with its price.
- Contracts pinned to v0.146.0, which declares `where`. The schema proposal copy now mirrors the release, and the docs blocks are refreshed.
- `test/api-join.test.js` (7 checks); the full suite passes (18/18 files).

Changes

ddiff --git a/README.md b/README.md
iindex 98e8d30..c4f3048 100644
---- a/README.md
++++ b/README.md
@@@ -116,6 +116,16 @@ an empty success.
"extra": { "tld": "$key", "registration": "registration", "renewal": "renewal" } }
```
++A path segment `*` steps into the single child of an object (a path that meets an array, or an
++object with zero or more than one own property, resolves to null). A path starting with `$root.`
++resolves from the parsed response body rather than the record, and inside it `{$key}` stands for
++the record's map key — so a keyed price list can be joined to prices held elsewhere in the same
++response: `"price_hourly": "$root.terms.OnDemand.{$key}.*.priceDimensions.*.pricePerUnit.USD"`.
++An endpoint may also carry `where`, up to 10 `path -> expected` conditions (expected is a string,
++number or boolean, or an array of up to 20 of them, matching any): a record that fails any
++condition is left out before `maxItems` is counted and reported as `meta.filtered`, e.g.
++`"where": { "productFamily": "Lightsail Instance", "attributes.operatingSystem": "Linux" }`.
++
### Items
Each item: `source_key`, `category`, `kind`, `identity`, `canonical_url` (tracking parameters and
@@@ -345,7 +355,7 @@ vhost serves it.
Part of the [OpenVibe network](https://openvibe.network). Built in the open by [OpenVibers](https://github.com/OpenVibers).
<!-- versions:start -->
--- openvibe-contracts: v0.143.0
++- openvibe-contracts: v0.146.0
- openvibe-sdk: v0.44.0
- openvibe-shared: v4.0.0
<!-- versions:end -->
ddiff --git a/STATUS.json b/STATUS.json
iindex 2fb3bee..d7c4a3c 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -7,7 +7,7 @@
"runtime": "node 22, express, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9, fast-xml-parser",
"port": 4720,
"domain": "sources.openvibe.network (vhost in deploy/nginx: an internal-service page, health and JSON 404s; the API is loopback-only; README \"Public host\")",
-- "contracts": "openvibe-contracts v0.143.0",
++ "contracts": "openvibe-contracts v0.146.0",
"sdk": "openvibe-sdk v0.44.0",
"shared": "openvibe-shared v4.0.0",
"capabilities": [
@@@ -44,5 +44,5 @@
"staff-only index documents for Search",
"per-actor limits (openvibe-sdk/limits)"
],
-- "updated": "2026-10-11"
++ "updated": "2026-10-12"
}
ddiff --git a/docs/contracts-proposal/contracts/sources/source.v1.json b/docs/contracts-proposal/contracts/sources/source.v1.json
iindex e406e51..a79c42f 100644
---- a/docs/contracts-proposal/contracts/sources/source.v1.json
++++ b/docs/contracts-proposal/contracts/sources/source.v1.json
@@@ -4,64 +4,303 @@
"title": "Source",
"description": "A registered source (roadmap §4.2 B, Wave 14): where it is fetched from and on what terms. Credentials are referenced by environment-variable NAME only. A source's existence is never permission to fetch: robots and terms are verified before it is enabled, and the fetcher re-checks robots.txt on every run.",
"type": "object",
-- "required": ["key", "name", "type", "category", "endpoints", "auth", "min_interval_ms", "poll_interval_sec", "stale_after_sec", "max_items", "enabled", "review_required", "sensitivity", "default_indexability"],
++ "required": [
++ "key",
++ "name",
++ "type",
++ "category",
++ "endpoints",
++ "auth",
++ "min_interval_ms",
++ "poll_interval_sec",
++ "stale_after_sec",
++ "max_items",
++ "enabled",
++ "review_required",
++ "sensitivity",
++ "default_indexability"
++ ],
"additionalProperties": false,
"properties": {
-- "key": { "type": "string", "pattern": "^[a-z0-9][a-z0-9-]{1,63}$" },
-- "name": { "type": "string", "minLength": 1, "maxLength": 200 },
-- "type": { "enum": ["rss", "atom", "sitemap", "jsonld", "api", "manual"] },
-- "category": { "enum": ["news", "blog", "reviews", "deals", "coupons", "trade", "hosting"] },
-- "homepage_url": { "type": ["string", "null"], "format": "uri" },
++ "key": {
++ "type": "string",
++ "pattern": "^[a-z0-9][a-z0-9-]{1,63}$"
++ },
++ "name": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 200
++ },
++ "type": {
++ "enum": [
++ "rss",
++ "atom",
++ "sitemap",
++ "jsonld",
++ "api",
++ "manual"
++ ]
++ },
++ "category": {
++ "enum": [
++ "news",
++ "blog",
++ "reviews",
++ "deals",
++ "coupons",
++ "trade",
++ "hosting"
++ ]
++ },
++ "homepage_url": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "format": "uri"
++ },
"endpoints": {
-- "type": "array", "maxItems": 20,
++ "type": "array",
++ "maxItems": 20,
"items": {
-- "type": "object", "required": ["url"], "additionalProperties": false,
++ "type": "object",
++ "required": [
++ "url"
++ ],
++ "additionalProperties": false,
"properties": {
-- "url": { "type": "string", "format": "uri", "pattern": "^https?://" },
-- "format": { "enum": ["json", "xml"] },
-- "items_shape": { "enum": ["array", "map"], "description": "array (default): the value at items_path is a list of records. map: it is an object keyed by id, each own property a record, in sorted key order; the key is readable as the virtual path $key." },
-- "items_path": { "type": "string" },
-- "item_kind": { "type": "string", "pattern": "^[a-z][a-z0-9_]{1,39}$" },
-- "fields": { "type": "object", "additionalProperties": { "type": "string" } },
-- "extra": { "type": "object", "additionalProperties": { "type": "string" } }
++ "url": {
++ "type": "string",
++ "format": "uri",
++ "pattern": "^https?://"
++ },
++ "format": {
++ "enum": [
++ "json",
++ "xml"
++ ]
++ },
++ "items_path": {
++ "type": "string"
++ },
++ "items_shape": {
++ "enum": [
++ "array",
++ "map"
++ ],
++ "description": "array (default): items_path names a JSON array of records. map: items_path names an object whose own properties are the records, in sorted key order, with the key available as the path $key (Sources#37)."
++ },
++ "where": {
++ "type": "object",
++ "maxProperties": 10,
++ "additionalProperties": {
++ "oneOf": [
++ {
++ "type": [
++ "string",
++ "number",
++ "boolean"
++ ]
++ },
++ {
++ "type": "array",
++ "minItems": 1,
++ "maxItems": 20,
++ "items": {
++ "type": [
++ "string",
++ "number",
++ "boolean"
++ ]
++ }
++ }
++ ]
++ },
++ "description": "Records kept only when every path equals its value (or one of an array of values); others are left out before max_items counts. Paths follow the field rules: relative to the record, $key, an absolute $root.<path> into the response (a {$key} segment there is the record's map key), and * for an object's single child (Sources#39)."
++ },
++ "item_kind": {
++ "type": "string",
++ "pattern": "^[a-z][a-z0-9_]{1,39}$"
++ },
++ "fields": {
++ "type": "object",
++ "additionalProperties": {
++ "type": "string"
++ }
++ },
++ "extra": {
++ "type": "object",
++ "additionalProperties": {
++ "type": "string"
++ },
++ "description": "Extra fields copied as the API gave them (scalars). A path is relative to the record; $key is the map key; $root.<path> reads the response from its root, where a {$key} segment is the record's map key; * steps into an object's single child."
++ }
}
}
},
"auth": {
-- "type": "object", "required": ["mode"], "additionalProperties": false,
++ "type": "object",
++ "required": [
++ "mode"
++ ],
++ "additionalProperties": false,
"properties": {
-- "mode": { "enum": ["none", "header", "bearer", "query"] },
-- "env": { "type": "string", "pattern": "^SOURCES_CRED_[A-Z0-9_]{2,60}$", "description": "Name of the environment variable holding the credential. Never a value." },
-- "header": { "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9-]{0,63}$" },
-- "param": { "type": "string", "pattern": "^[A-Za-z0-9_.-]{1,64}$" }
++ "mode": {
++ "enum": [
++ "none",
++ "header",
++ "bearer",
++ "query"
++ ]
++ },
++ "env": {
++ "type": "string",
++ "pattern": "^SOURCES_CRED_[A-Z0-9_]{2,60}$",
++ "description": "Name of the environment variable holding the credential. Never a value."
++ },
++ "header": {
++ "type": "string",
++ "pattern": "^[A-Za-z][A-Za-z0-9-]{0,63}$"
++ },
++ "param": {
++ "type": "string",
++ "pattern": "^[A-Za-z0-9_.-]{1,64}$"
++ }
}
},
-- "robots_note": { "type": ["string", "null"], "maxLength": 2000 },
-- "terms_note": { "type": ["string", "null"], "maxLength": 2000 },
-- "license_note": { "type": ["string", "null"], "maxLength": 2000 },
-- "min_interval_ms": { "type": "integer", "minimum": 0, "description": "Rate limit: the least time between two requests to this source (the per-host floor and robots Crawl-delay may raise it)." },
-- "poll_interval_sec": { "type": "integer", "minimum": 1 },
-- "stale_after_sec": { "type": "integer", "minimum": 1 },
-- "max_items": { "type": "integer", "minimum": 1 },
-- "enabled": { "type": "boolean" },
-- "review_required": { "type": "boolean", "description": "Products must review what they derive from this source before it can be indexable." },
-- "sensitivity": { "enum": ["none", "financial", "health", "political", "legal", "adult"] },
-- "default_indexability": { "enum": ["index", "noindex"], "description": "What a product's indexability gate starts from for content derived from this source." },
-- "search_visibility": { "type": ["string", "null"], "enum": ["members", null], "description": "members = raw items are indexed in OpenVibe.Search for staff only (never indexable)." },
++ "robots_note": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "maxLength": 2000
++ },
++ "terms_note": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "maxLength": 2000
++ },
++ "license_note": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "maxLength": 2000
++ },
++ "min_interval_ms": {
++ "type": "integer",
++ "minimum": 0,
++ "description": "Rate limit: the least time between two requests to this source (the per-host floor and robots Crawl-delay may raise it)."
++ },
++ "poll_interval_sec": {
++ "type": "integer",
++ "minimum": 1
++ },
++ "stale_after_sec": {
++ "type": "integer",
++ "minimum": 1
++ },
++ "max_items": {
++ "type": "integer",
++ "minimum": 1
++ },
++ "enabled": {
++ "type": "boolean"
++ },
++ "review_required": {
++ "type": "boolean",
++ "description": "Products must review what they derive from this source before it can be indexable."
++ },
++ "sensitivity": {
++ "enum": [
++ "none",
++ "financial",
++ "health",
++ "political",
++ "legal",
++ "adult"
++ ]
++ },
++ "default_indexability": {
++ "enum": [
++ "index",
++ "noindex"
++ ],
++ "description": "What a product's indexability gate starts from for content derived from this source."
++ },
++ "search_visibility": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "enum": [
++ "members",
++ null
++ ],
++ "description": "members = raw items are indexed in OpenVibe.Search for staff only (never indexable)."
++ },
"health": {
-- "type": "object", "additionalProperties": false,
++ "type": "object",
++ "additionalProperties": false,
"properties": {
-- "status": { "enum": ["healthy", "stale", "failing", "never_fetched", "disabled", "manual"] },
-- "stale": { "type": "boolean" },
-- "last_success_at": { "type": ["string", "null"], "format": "date-time" },
-- "last_run_at": { "type": ["string", "null"], "format": "date-time" },
-- "last_state": { "type": ["string", "null"] },
-- "consecutive_failures": { "type": "integer", "minimum": 0 },
-- "next_due_at": { "type": ["string", "null"], "format": "date-time" },
-- "stale_after_sec": { "type": "integer" }
++ "status": {
++ "enum": [
++ "healthy",
++ "stale",
++ "failing",
++ "never_fetched",
++ "disabled",
++ "manual"
++ ]
++ },
++ "stale": {
++ "type": "boolean"
++ },
++ "last_success_at": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "format": "date-time"
++ },
++ "last_run_at": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "format": "date-time"
++ },
++ "last_state": {
++ "type": [
++ "string",
++ "null"
++ ]
++ },
++ "consecutive_failures": {
++ "type": "integer",
++ "minimum": 0
++ },
++ "next_due_at": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "format": "date-time"
++ },
++ "stale_after_sec": {
++ "type": "integer"
++ }
}
},
-- "created_at": { "type": "string", "format": "date-time" },
-- "updated_at": { "type": "string", "format": "date-time" }
++ "created_at": {
++ "type": "string",
++ "format": "date-time"
++ },
++ "updated_at": {
++ "type": "string",
++ "format": "date-time"
++ }
}
}
ddiff --git a/package-lock.json b/package-lock.json
iindex 1b3cfb3..c7c6f3f 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -12,7 +12,7 @@
"dotenv": "^16.4.7",
"express": "^4.21.2",
"fast-xml-parser": "^4.5.3",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.143.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.146.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.44.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.0.0",
"pg": "^8.23.0"
@@@ -769,9 +769,9 @@
}
},
"node_modules/openvibe-contracts": {
-- "version": "0.143.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.143.0",
-- "integrity": "sha512-Cdbf+9RoPAUBibpM2zXQvtsiKMugb55dKubTt8pFZvAmxsLtGvX+zaC++yL1vOSub8FX3+GgqcGtoJS6tklrUg==",
++ "version": "0.146.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.146.0",
++ "integrity": "sha512-A/JyuuPvQd0JytCOnjSfO6GQgHuNsg8UjAsWLKriSuiDpHQKHvZFsxZ+U/riFN7H5KDxgMB8Okv2nLRyLuNIAQ==",
"license": "AGPL-3.0-only",
"dependencies": {
"ajv": "^8.17.1",
ddiff --git a/package.json b/package.json
iindex ab66db7..911db2b 100644
---- a/package.json
++++ b/package.json
@@@ -27,7 +27,7 @@
"dotenv": "^16.4.7",
"express": "^4.21.2",
"fast-xml-parser": "^4.5.3",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.143.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.146.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.44.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.0.0",
"pg": "^8.23.0"
ddiff --git a/seeds/sources.json b/seeds/sources.json
iindex dd83dbb..4a606ed 100644
---- a/seeds/sources.json
++++ b/seeds/sources.json
@@@ -289,6 +289,51 @@
"review_required": true,
"sensitivity": "none",
"default_indexability": "noindex"
++ },
++ {
++ "key": "aws-lightsail-bundles",
++ "name": "Amazon Lightsail instance bundles (US East, N. Virginia)",
++ "type": "api",
++ "category": "hosting",
++ "homepage_url": "https://aws.amazon.com/lightsail/pricing/",
++ "endpoints": [
++ {
++ "url": "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonLightsail/current/us-east-1/index.json",
++ "format": "json",
++ "items_shape": "map",
++ "items_path": "products",
++ "item_kind": "plan",
++ "where": {
++ "productFamily": "Lightsail Instance",
++ "attributes.operatingSystem": "Linux"
++ },
++ "fields": {
++ "identity": "$key",
++ "title": "attributes.usagetype"
++ },
++ "extra": {
++ "usagetype": "attributes.usagetype",
++ "vcpu": "attributes.vcpu",
++ "memory": "attributes.memory",
++ "storage": "attributes.storage",
++ "transfer": "attributes.dataTransferQuota",
++ "location": "attributes.location",
++ "region": "attributes.regionCode",
++ "price_hourly": "$root.terms.OnDemand.{$key}.*.priceDimensions.*.pricePerUnit.USD",
++ "price_unit": "$root.terms.OnDemand.{$key}.*.priceDimensions.*.unit"
++ }
++ }
++ ],
++ "robots_note": "robots.txt checked 2026-10-12: https://pricing.us-east-1.amazonaws.com/robots.txt answers 404 (an S3 NoSuchKey error), so the price list host publishes no robots.txt rules; a 404 is treated as no restrictions (never guessed as a disallow). The fetcher re-checks robots.txt on every run.",
++ "terms_note": "This is the AWS Price List bulk API, AWS's documented keyless way to read its prices by program. Every file carries its own disclaimer: 'This pricing list is for informational purposes only. All prices are subject to the additional terms included in the pricing pages on http://aws.amazon.com.' Show each price as an observation 'checked on <date>' with a link to https://aws.amazon.com/lightsail/pricing/, never as a quote, and imply no endorsement. One file (about 230 KB) a day. Re-verify before enabling.",
++ "license_note": "Prices and bundle specifications are Amazon Web Services' data. No licence to republish them was found beyond the informational disclaimer above. Attribute AWS and link the Lightsail pricing page.",
++ "min_interval_ms": 60000,
++ "poll_interval_sec": 86400,
++ "max_items": 200,
++ "enabled": false,
++ "review_required": true,
++ "sensitivity": "none",
++ "default_indexability": "noindex"
}
]
}
ddiff --git a/server/adapters/api.js b/server/adapters/api.js
iindex 409c08a..e2ed31c 100644
---- a/server/adapters/api.js
++++ b/server/adapters/api.js
@@@ -13,28 +13,74 @@
* the array. `extra` values are copied as the API gave them (scalars only, strings capped):
* a missing field is null, never a default. A record without an identity is skipped.
*
++ * A path segment `*` steps into the single child of an object: when the current value is a
++ * non-array object with exactly one own property it steps into that property, and otherwise the
++ * path resolves to undefined (so the field is null). It may appear anywhere in a path.
++ *
++ * A path starting with `$root.` is resolved from the parsed response body rather than the record,
++ * so a record can reach a value kept elsewhere in the same response. Inside such a path a segment
++ * written `{$key}` is the record's map key (only valid when `items_shape` is "map", and only
++ * inside `$root.`). Example — join an AWS Lightsail SKU to its price:
++ *
++ * "extra": { "price_hourly": "$root.terms.OnDemand.{$key}.*.priceDimensions.*.pricePerUnit.USD" }
++ *
++ * An endpoint may carry `where`: an object of up to 10 `path -> expected` conditions whose paths
++ * follow the same rules (relative, `$key`, `$root.`, `*`). Expected is a string, number or boolean
++ * (compared to the value after `scalar()`), or an array of up to 20 such values (any of them
++ * matches). A record that fails any condition is left out before `maxItems` is counted and
++ * reported as `meta.filtered`, not `skipped`:
++ *
++ * "where": { "productFamily": "Lightsail Instance", "attributes.operatingSystem": "Linux" }
++ *
* Some price lists key their records by id instead of listing them: Porkbun returns
* `{ "pricing": { "com": {…}, "net": {…} } }` and AWS Lightsail keys its price list by SKU.
* `"items_shape": "map"` reads such an object: each own property becomes one record, in sorted
* key order, and the key is available as the virtual path `$key` (so `fields.identity: "$key"`
-- * makes the id the identity). `"array"` (the default) is today's behaviour. A property whose value
-- * is not an object is skipped; a non-object `items_path` is a parse error, never an empty success.
++ * makes the id the identity) or as `{$key}` inside a `$root.` path. `"array"` (the default) is
++ * today's behaviour. A property whose value is not an object is skipped; a non-object
++ * `items_path` is a parse error, never an empty success.
*/
const { parseXml, toText, toIsoDate, epochSecondsToIso, canonicalUrl, toId, ParseError } = require('./util');
const PARSER_VERSION = 'api@1';
--const PATH_RE = /^[A-Za-z0-9_@:$-]+(\.[A-Za-z0-9_@:$-]+)*$/;
++// A segment is `*` (the single child of an object), `{$key}` (the map key, only inside `$root.`),
++// or an ordinary key. PATH_RE is syntax only; keyPathViolation enforces where each token may sit.
++const PATH_SEG = '(?:\\*|\\{\\$key\\}|[A-Za-z0-9_@:$-]+)';
++const PATH_RE = new RegExp(`^${PATH_SEG}(?:\\.${PATH_SEG})*$`);
--function at(obj, path) {
-- if (!path) return obj;
++/** Walk a dot path: `*` steps into an object's single own property, `{$key}` uses the map key. */
++function walk(obj, path, key) {
let cur = obj;
for (const seg of path.split('.')) {
if (cur == null || typeof cur !== 'object') return undefined;
-- cur = cur[seg];
++ if (seg === '*') {
++ if (Array.isArray(cur)) return undefined;
++ const own = Object.keys(cur);
++ if (own.length !== 1) return undefined;
++ cur = cur[own[0]];
++ } else if (seg === '{$key}') {
++ if (key === undefined) return undefined;
++ cur = cur[key];
++ } else {
++ cur = cur[seg];
++ }
}
return cur;
}
++/** A record's value: `$key` is its map key, `$root.…` resolves from the body, else the record. */
++function getField(body, rec, key, p) {
++ if (!p) return rec;
++ if (p === '$key') return key;
++ if (p.startsWith('$root.')) return walk(body, p.slice('$root.'.length), key);
++ return walk(rec, p, key);
++}
++
++function at(obj, path) {
++ if (!path) return obj;
++ return walk(obj, path, undefined);
++}
++
function scalar(v) {
if (v == null) return null;
if (typeof v === 'number' || typeof v === 'boolean') return Number.isFinite(v) || typeof v === 'boolean' ? v : null;
@@@ -43,11 +89,33 @@ function scalar(v) {
return null;
}
--/** `$key` is the map key: only as a whole path, and only when items_shape is "map". */
++/** Does one record satisfy every `where` condition? Values compare after scalar(). */
++function matchesWhere(body, rec, key, where) {
++ for (const [p, expected] of Object.entries(where)) {
++ const actual = scalar(getField(body, rec, key, p));
++ const wanted = Array.isArray(expected) ? expected : [expected];
++ if (!wanted.some((w) => w === actual)) return false;
++ }
++ return true;
++}
++
++/**
++ * `$key` is the map key: as a whole relative path only when items_shape is "map", or as the
++ * segment `{$key}` inside a `$root.` path. `$root` must be followed by a path.
++ */
function keyPathViolation(p, shape) {
-- if (!p.includes('$key')) return null;
-- if (p !== '$key') return 'a $key path is exactly "$key"';
-- if (shape !== 'map') return '$key requires items_shape "map"';
++ const segs = p.split('.');
++ const hasBare = segs.includes('$key');
++ const hasBrace = segs.includes('{$key}');
++ if (p === '$root' || p.startsWith('$root.')) {
++ if (p === '$root') return '$root must be followed by a path';
++ if (hasBare) return 'inside a $root. path, use "{$key}" for the record key';
++ if (hasBrace && shape !== 'map') return '{$key} requires items_shape "map"';
++ return null;
++ }
++ if (hasBrace) return '{$key} is only valid inside a $root. path';
++ if (p === '$key') return shape === 'map' ? null : '$key requires items_shape "map"';
++ if (hasBare) return 'a $key path is exactly "$key"';
return null;
}
@@@ -72,6 +140,19 @@ function checkMapping(ep) {
const kerr = keyPathViolation(p, shape);
if (kerr) return `extra.${k}: ${kerr}`;
}
++ const where = ep.where == null ? {} : ep.where;
++ if (typeof where !== 'object' || Array.isArray(where)) return 'where must be an object of path -> expected';
++ if (Object.keys(where).length > 10) return 'at most 10 where conditions';
++ for (const [p, expected] of Object.entries(where)) {
++ if (!PATH_RE.test(p)) return `where.${p} path is malformed`;
++ const kerr = keyPathViolation(p, shape);
++ if (kerr) return `where.${p}: ${kerr}`;
++ const wanted = Array.isArray(expected) ? expected : [expected];
++ if (wanted.length > 20) return `where.${p} has at most 20 expected values`;
++ for (const v of wanted) {
++ if (typeof v !== 'string' && typeof v !== 'number' && typeof v !== 'boolean') return `where.${p} expectations must be strings, numbers or booleans`;
++ }
++ }
if (ep.item_kind !== undefined && !/^[a-z][a-z0-9_]{1,39}$/.test(ep.item_kind)) return 'item_kind is malformed';
return null;
}
@@@ -95,12 +176,17 @@ function parse(text, { url, endpoint = {}, summaryMax = 1000, maxItems = 500 } =
records = node.map((value) => ({ key: undefined, value }));
}
const f = endpoint.fields || {};
++ const where = endpoint.where && typeof endpoint.where === 'object' && !Array.isArray(endpoint.where) ? endpoint.where : {};
++ const filterKeys = Object.keys(where);
const items = [];
let skipped = 0;
++ let filtered = 0;
const seen = new Set();
for (const { key, value: rec } of records) {
-- if (items.length >= maxItems || !rec || typeof rec !== 'object' || (shape === 'map' && Array.isArray(rec))) { skipped++; continue; }
-- const get = (p) => (shape === 'map' && p === '$key' ? key : at(rec, p));
++ if (!rec || typeof rec !== 'object' || (shape === 'map' && Array.isArray(rec))) { skipped++; continue; }
++ if (filterKeys.length && !matchesWhere(body, rec, key, where)) { filtered++; continue; }
++ if (items.length >= maxItems) { skipped++; continue; }
++ const get = (p) => getField(body, rec, key, p);
const link = f.url ? canonicalUrl(scalar(get(f.url)), url) : null;
const identity = (f.identity ? toId(scalar(get(f.identity))) : null) || link;
if (!identity || seen.has(identity)) { skipped++; continue; }
@@@ -119,7 +205,7 @@ function parse(text, { url, endpoint = {}, summaryMax = 1000, maxItems = 500 } =
fields: extra,
});
}
-- return { items, skipped, meta: { format } };
++ return { items, skipped, meta: { format, filtered } };
}
module.exports = { parse, PARSER_VERSION, checkMapping };
ddiff --git a/server/registry.js b/server/registry.js
iindex 3c69e6c..4c91224 100644
---- a/server/registry.js
++++ b/server/registry.js
@@@ -74,6 +74,8 @@ function validateSource(input, { maxItemsCap = 500 } = {}) {
const err = checkApiMapping(e);
if (err) throw new RegistryError(`endpoints[${i}]: ${err}`);
Object.assign(out, { format: e.format || 'json', items_shape: e.items_shape || 'array', items_path: e.items_path || '', item_kind: e.item_kind || 'record', fields: e.fields || {}, extra: e.extra || {} });
++ // `where` is only copied when given, so records without conditions stay byte-identical.
++ if (e.where) out.where = e.where;
} else {
for (const k of Object.keys(e)) if (k !== 'url') throw new RegistryError(`endpoints[${i}].${k} is only for api sources`);
}
ddiff --git a/test/api-join.test.js b/test/api-join.test.js
nnew file mode 100644
iindex 0000000..94acbe3
---- /dev/null
++++ b/test/api-join.test.js
@@@ -0,0 +1,146 @@
++'use strict';
++/**
++ * API adapter: `*` (the single child of an object), `$root.` paths with `{$key}`, and `where`
++ * filtering. The fixture is shaped like the AWS Lightsail price list: `products` is keyed by SKU
++ * and the prices live under `terms.OnDemand.<SKU>.<offer>.priceDimensions.<dim>.pricePerUnit.USD`,
++ * where the `<offer>` and `<dim>` keys are generated and unknown.
++ */
++const assert = require('assert');
++const api = require('../server/adapters/api');
++const { suite } = require('./helpers');
++
++const t = suite('api-join');
++
++const LIGHTSAIL = JSON.stringify({
++ products: {
++ 'SKU-LINUX': { productFamily: 'Lightsail Instance', attributes: { operatingSystem: 'Linux', vcpu: 2, memory: '4 GiB', bundled: true } },
++ 'SKU-WIN': { productFamily: 'Lightsail Instance', attributes: { operatingSystem: 'Windows', vcpu: 2, memory: '4 GiB', bundled: true } },
++ 'SKU-STORE': { productFamily: 'Storage', attributes: { storageMedia: 'SSD', vcpu: 2 } },
++ 'SKU-NOPRICE': { productFamily: 'Lightsail Instance', attributes: { operatingSystem: 'Linux', vcpu: 1, memory: '1 GiB', bundled: false } },
++ },
++ terms: {
++ OnDemand: {
++ 'SKU-LINUX': { 'SKU-LINUX.offer': { priceDimensions: { 'SKU-LINUX.dim': { pricePerUnit: { USD: '0.024' }, unit: 'Hrs' } } } },
++ 'SKU-WIN': { 'SKU-WIN.offer': { priceDimensions: { 'SKU-WIN.dim': { pricePerUnit: { USD: '0.048' }, unit: 'Hrs' } } } },
++ 'SKU-STORE': { 'SKU-STORE.offer': { priceDimensions: { 'SKU-STORE.dim': { pricePerUnit: { USD: '0.10' }, unit: 'GB-Mo' } } } },
++ },
++ },
++});
++
++const joinEndpoint = {
++ format: 'json', items_path: 'products', items_shape: 'map', item_kind: 'instance',
++ fields: { identity: '$key', title: 'attributes.operatingSystem' },
++ extra: {
++ family: 'productFamily',
++ os: 'attributes.operatingSystem',
++ vcpu: 'attributes.vcpu',
++ price_hourly: '$root.terms.OnDemand.{$key}.*.priceDimensions.*.pricePerUnit.USD',
++ unit: '$root.terms.OnDemand.{$key}.*.priceDimensions.*.unit',
++ },
++};
++
++t('a `*` segment steps into the single child of an object; arrays and multi-key objects give null', () => {
++ const endpoint = {
++ format: 'json', items_path: 'rows', fields: { identity: 'id' },
++ extra: { one: 'obj.*.v', many: 'multi.*.v', arr: 'list.*.v', deeper: 'obj.*.nope' },
++ };
++ assert.strictEqual(api.checkMapping(endpoint), null);
++ const body = JSON.stringify({ rows: [{ id: 'a', obj: { inner: { v: 'yes' } }, multi: { x: 1, y: 2 }, list: [{ v: 'no' }] }] });
++ const r = api.parse(body, { url: 'https://x.example/', endpoint });
++ assert.deepStrictEqual(r.items[0].fields, { one: 'yes', many: null, arr: null, deeper: null });
++});
++
++t('$root paths join a keyed record to prices elsewhere in the response, using {$key} and *', () => {
++ assert.strictEqual(api.checkMapping(joinEndpoint), null);
++ const r = api.parse(LIGHTSAIL, { url: 'https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/Lightsail/current/index.json', endpoint: joinEndpoint });
++ assert.deepStrictEqual(r.items.map((i) => i.identity), ['SKU-LINUX', 'SKU-NOPRICE', 'SKU-STORE', 'SKU-WIN']);
++ assert.deepStrictEqual([r.skipped, r.meta.filtered], [0, 0]);
++ const by = Object.fromEntries(r.items.map((i) => [i.identity, i.fields]));
++ assert.strictEqual(by['SKU-LINUX'].price_hourly, '0.024');
++ assert.strictEqual(by['SKU-WIN'].price_hourly, '0.048');
++ assert.strictEqual(by['SKU-STORE'].price_hourly, '0.10');
++ assert.strictEqual(by['SKU-NOPRICE'].price_hourly, null, 'no terms entry for this SKU -> null');
++ assert.deepStrictEqual([by['SKU-LINUX'].unit, by['SKU-STORE'].unit, by['SKU-NOPRICE'].unit], ['Hrs', 'GB-Mo', null]);
++ assert.strictEqual(r.items.find((i) => i.identity === 'SKU-WIN').title, 'Windows', 'record-relative fields still resolve');
++});
++
++t('`where` keeps records matching every condition and counts the rest in meta.filtered', () => {
++ const linux = { ...joinEndpoint, where: { productFamily: 'Lightsail Instance', 'attributes.operatingSystem': 'Linux' } };
++ assert.strictEqual(api.checkMapping(linux), null);
++ const r = api.parse(LIGHTSAIL, { url: 'https://x.example/', endpoint: linux });
++ assert.deepStrictEqual(r.items.map((i) => i.identity), ['SKU-LINUX', 'SKU-NOPRICE']);
++ assert.strictEqual(r.meta.filtered, 2, 'SKU-STORE (family) and SKU-WIN (os)');
++ assert.strictEqual(r.skipped, 0, 'filtered records are not skipped');
++
++ // an array expectation matches any; a number compares after scalar()
++ const any = { ...joinEndpoint, where: { 'attributes.operatingSystem': ['Linux', 'Windows'], 'attributes.vcpu': 2 } };
++ const r2 = api.parse(LIGHTSAIL, { url: 'https://x.example/', endpoint: any });
++ assert.deepStrictEqual(r2.items.map((i) => i.identity), ['SKU-LINUX', 'SKU-WIN']);
++ assert.strictEqual(r2.meta.filtered, 2, 'SKU-NOPRICE is vcpu 1; SKU-STORE has no operatingSystem');
++
++ // a boolean condition, and `$key` as a where path
++ const keyed = { ...joinEndpoint, where: { 'attributes.bundled': true, '$key': ['SKU-LINUX', 'SKU-WIN', 'SKU-STORE'] } };
++ assert.strictEqual(api.checkMapping(keyed), null);
++ const r3 = api.parse(LIGHTSAIL, { url: 'https://x.example/', endpoint: keyed });
++ assert.deepStrictEqual(r3.items.map((i) => i.identity), ['SKU-LINUX', 'SKU-WIN']);
++ assert.strictEqual(r3.meta.filtered, 2, 'SKU-STORE has no bundled; SKU-NOPRICE is false');
++});
++
++t('maxItems counts only kept records; filtering runs before the cap', () => {
++ const ep = { ...joinEndpoint, where: { productFamily: 'Lightsail Instance', 'attributes.operatingSystem': 'Linux' } };
++ const r = api.parse(LIGHTSAIL, { url: 'https://x.example/', endpoint: ep, maxItems: 1 });
++ assert.deepStrictEqual(r.items.map((i) => i.identity), ['SKU-LINUX']);
++ assert.strictEqual(r.meta.filtered, 2, 'the two non-Linux records are filtered, not skipped');
++ assert.strictEqual(r.skipped, 1, 'SKU-NOPRICE passed the filter but is past maxItems');
++});
++
++t('checkMapping validates the new syntax and rejects malformed variants', () => {
++ const map = { items_shape: 'map', fields: { identity: '$key' } };
++ assert.strictEqual(api.checkMapping(joinEndpoint), null);
++ assert.strictEqual(api.checkMapping({ ...map, where: { '$key': ['a'], x: 'y', '*' : 'z' } }), null);
++ // {$key} outside a $root. path
++ assert.match(api.checkMapping({ ...map, extra: { x: '{$key}' } }), /\{\$key\} is only valid inside a \$root\. path/);
++ assert.match(api.checkMapping({ ...map, where: { 'a.{$key}': 'x' } }), /\{\$key\} is only valid inside a \$root\. path/);
++ // $root alone must be followed by a path
++ assert.match(api.checkMapping({ ...map, extra: { x: '$root' } }), /\$root must be followed by a path/);
++ // $key mixed into a longer path, and bare $key inside a $root. path
++ assert.match(api.checkMapping({ ...map, extra: { x: 'a.$key' } }), /exactly "\$key"/);
++ assert.match(api.checkMapping({ ...map, extra: { x: '$root.a.$key' } }), /use "\{\$key\}"/);
++ // {$key} still needs items_shape "map"
++ assert.match(api.checkMapping({ fields: { identity: 'a' }, extra: { x: '$root.a.{$key}' } }), /\{\$key\} requires items_shape "map"/);
++ // where expectations must be scalars, at most 20 of them
++ assert.match(api.checkMapping({ ...map, where: { x: { y: 1 } } }), /strings, numbers or booleans/);
++ assert.match(api.checkMapping({ ...map, where: { x: [null] } }), /strings, numbers or booleans/);
++ assert.match(api.checkMapping({ ...map, where: { x: Array.from({ length: 21 }, (_, i) => i) } }), /at most 20/);
++ // at most 10 conditions, and where itself is an object
++ const eleven = Object.fromEntries(Array.from({ length: 11 }, (_, i) => [`k${i}`, 'v']));
++ assert.match(api.checkMapping({ ...map, where: eleven }), /at most 10/);
++ assert.match(api.checkMapping({ ...map, where: ['x'] }), /where must be an object/);
++});
++
++t('the registry stores `where` only when an endpoint gives it', () => {
++ const { validateSource } = require('../server/registry');
++ const endpoint = { url: 'https://x.example/', items_path: 'products', items_shape: 'map', fields: { identity: '$key' } };
++ const base = { key: 'lightsail', name: 'Lightsail prices', type: 'api', category: 'hosting', endpoints: [endpoint] };
++ assert.ok(!('where' in validateSource(base).endpoints[0]));
++ const stored = validateSource({ ...base, endpoints: [{ ...endpoint, where: { productFamily: 'Lightsail Instance' } }] });
++ assert.deepStrictEqual(stored.endpoints[0].where, { productFamily: 'Lightsail Instance' });
++});
++
++t('an endpoint without the new keys parses exactly as before', () => {
++ const endpoint = {
++ format: 'json', items_path: 'deals', item_kind: 'offer',
++ fields: { identity: 'dealID', url: 'link' },
++ extra: { sale_price: 'salePrice' },
++ };
++ assert.strictEqual(api.checkMapping(endpoint), null);
++ const r = api.parse('{"deals":[{"dealID":"a","link":"https://shop.example/d/a","salePrice":"4.99"},null,{"dealID":"b"}]}', { url: 'https://api.example.com/deals', endpoint });
++ assert.deepStrictEqual(r.items.map((i) => i.identity), ['a', 'b']);
++ assert.strictEqual(r.skipped, 1, 'the null element');
++ assert.strictEqual(r.meta.filtered, 0, 'no where -> nothing filtered');
++ assert.strictEqual(r.items[0].canonical_url, 'https://shop.example/d/a');
++ assert.strictEqual(r.items[0].fields.sale_price, '4.99');
++ assert.strictEqual(r.items[1].canonical_url, null);
++});
++
++t.run();