openvibers/openvibe.sources Commits
Sources: api paths join across the response ($root, {$key}, *) and filter records (where); AWS Lightsail seed (#39)
Changes
@@@ -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" }`.++### ItemsEach 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 -->@@@ -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"}@@@ -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"++ }}}@@@ -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",@@@ -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"@@@ -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"}]}@@@ -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 };@@@ -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`);}nnew file mode 100644@@@ -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();
@@ -116,6 +116,16 @@ an empty success.
@@ -116,6 +116,16 @@ an empty success.
"extra": { "tld": "$key", "registration": "registration", "renewal": "renewal" } }
"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
### Items
Each item: `source_key`, `category`, `kind`, `identity`, `canonical_url` (tracking parameters and
Each item: `source_key`, `category`, `kind`, `identity`, `canonical_url` (tracking parameters and
@@ -345,7 +355,7 @@ vhost serves it.
@@ -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).
Part of the [OpenVibe network](https://openvibe.network). Built in the open by [OpenVibers](https://github.com/OpenVibers).
<!-- versions:start -->
<!-- versions:start -->
-- openvibe-contracts: v0.143.0
+- openvibe-contracts: v0.146.0
- openvibe-sdk: v0.44.0
- openvibe-sdk: v0.44.0
- openvibe-shared: v4.0.0
- openvibe-shared: v4.0.0
<!-- versions:end -->
<!-- versions:end -->
@@ -7,7 +7,7 @@
@@ -7,7 +7,7 @@
"runtime": "node 22, express, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9, fast-xml-parser",
"runtime": "node 22, express, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9, fast-xml-parser",
"port": 4720,
"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\")",
"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",
"sdk": "openvibe-sdk v0.44.0",
"shared": "openvibe-shared v4.0.0",
"shared": "openvibe-shared v4.0.0",
"capabilities": [
"capabilities": [
@@ -44,5 +44,5 @@
@@ -44,5 +44,5 @@
"staff-only index documents for Search",
"staff-only index documents for Search",
"per-actor limits (openvibe-sdk/limits)"
"per-actor limits (openvibe-sdk/limits)"
],
],
- "updated": "2026-10-11"
+ "updated": "2026-10-12"
}
}
@@ -4,64 +4,303 @@
@@ -4,64 +4,303 @@
"title": "Source",
"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.",
"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",
"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,
"additionalProperties": false,
"properties": {
"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": {
"endpoints": {
- "type": "array", "maxItems": 20,
+ "type": "array",
+ "maxItems": 20,
"items": {
"items": {
- "type": "object", "required": ["url"], "additionalProperties": false,
+ "type": "object",
+ "required": [
+ "url"
+ ],
+ "additionalProperties": false,
"properties": {
"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": {
"auth": {
- "type": "object", "required": ["mode"], "additionalProperties": false,
+ "type": "object",
+ "required": [
+ "mode"
+ ],
+ "additionalProperties": false,
"properties": {
"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": {
"health": {
- "type": "object", "additionalProperties": false,
+ "type": "object",
+ "additionalProperties": false,
"properties": {
"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"
+ }
}
}
}
}
@@ -12,7 +12,7 @@
@@ -12,7 +12,7 @@
"dotenv": "^16.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express": "^4.21.2",
"fast-xml-parser": "^4.5.3",
"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-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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.0.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
@@ -769,9 +769,9 @@
@@ -769,9 +769,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"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",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -27,7 +27,7 @@
@@ -27,7 +27,7 @@
"dotenv": "^16.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express": "^4.21.2",
"fast-xml-parser": "^4.5.3",
"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-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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.0.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
@@ -289,6 +289,51 @@
@@ -289,6 +289,51 @@
"review_required": true,
"review_required": true,
"sensitivity": "none",
"sensitivity": "none",
"default_indexability": "noindex"
"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"
}
}
]
]
}
}
@@ -13,28 +13,74 @@
@@ -13,28 +13,74 @@
* the array. `extra` values are copied as the API gave them (scalars only, strings capped):
* 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 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
* 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.
* `{ "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
* `"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"`
* 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 { parseXml, toText, toIsoDate, epochSecondsToIso, canonicalUrl, toId, ParseError } = require('./util');
const PARSER_VERSION = 'api@1';
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;
let cur = obj;
for (const seg of path.split('.')) {
for (const seg of path.split('.')) {
if (cur == null || typeof cur !== 'object') return undefined;
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;
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) {
function scalar(v) {
if (v == null) return null;
if (v == null) return null;
if (typeof v === 'number' || typeof v === 'boolean') return Number.isFinite(v) || typeof v === 'boolean' ? v : null;
if (typeof v === 'number' || typeof v === 'boolean') return Number.isFinite(v) || typeof v === 'boolean' ? v : null;
@@ -43,11 +89,33 @@ function scalar(v) {
@@ -43,11 +89,33 @@ function scalar(v) {
return null;
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) {
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;
return null;
}
}
@@ -72,6 +140,19 @@ function checkMapping(ep) {
@@ -72,6 +140,19 @@ function checkMapping(ep) {
const kerr = keyPathViolation(p, shape);
const kerr = keyPathViolation(p, shape);
if (kerr) return `extra.${k}: ${kerr}`;
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';
if (ep.item_kind !== undefined && !/^[a-z][a-z0-9_]{1,39}$/.test(ep.item_kind)) return 'item_kind is malformed';
return null;
return null;
}
}
@@ -95,12 +176,17 @@ function parse(text, { url, endpoint = {}, summaryMax = 1000, maxItems = 500 } =
@@ -95,12 +176,17 @@ function parse(text, { url, endpoint = {}, summaryMax = 1000, maxItems = 500 } =
records = node.map((value) => ({ key: undefined, value }));
records = node.map((value) => ({ key: undefined, value }));
}
}
const f = endpoint.fields || {};
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 = [];
const items = [];
let skipped = 0;
let skipped = 0;
+ let filtered = 0;
const seen = new Set();
const seen = new Set();
for (const { key, value: rec } of records) {
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 link = f.url ? canonicalUrl(scalar(get(f.url)), url) : null;
const identity = (f.identity ? toId(scalar(get(f.identity))) : null) || link;
const identity = (f.identity ? toId(scalar(get(f.identity))) : null) || link;
if (!identity || seen.has(identity)) { skipped++; continue; }
if (!identity || seen.has(identity)) { skipped++; continue; }
@@ -119,7 +205,7 @@ function parse(text, { url, endpoint = {}, summaryMax = 1000, maxItems = 500 } =
@@ -119,7 +205,7 @@ function parse(text, { url, endpoint = {}, summaryMax = 1000, maxItems = 500 } =
fields: extra,
fields: extra,
});
});
}
}
- return { items, skipped, meta: { format } };
+ return { items, skipped, meta: { format, filtered } };
}
}
module.exports = { parse, PARSER_VERSION, checkMapping };
module.exports = { parse, PARSER_VERSION, checkMapping };
@@ -74,6 +74,8 @@ function validateSource(input, { maxItemsCap = 500 } = {}) {
@@ -74,6 +74,8 @@ function validateSource(input, { maxItemsCap = 500 } = {}) {
const err = checkApiMapping(e);
const err = checkApiMapping(e);
if (err) throw new RegistryError(`endpoints[${i}]: ${err}`);
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 || {} });
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 {
} else {
for (const k of Object.keys(e)) if (k !== 'url') throw new RegistryError(`endpoints[${i}].${k} is only for api sources`);
for (const k of Object.keys(e)) if (k !== 'url') throw new RegistryError(`endpoints[${i}].${k} is only for api sources`);
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,146 @@
@@ -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();