← back to Lifestyle Asset Intel
yolo tick #27: /api/sources ?sort= (weight/tier/name/slug/kind/enabled, default+fallback)
c3d51777f4acfed9d628921346e9383aa6edcc4d · 2026-05-13 23:46:08 -0700 · Steve Abrams
Files touched
M lib/openapi.jsM routes/api.jsM tests/smoke.test.js
Diff
commit c3d51777f4acfed9d628921346e9383aa6edcc4d
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Wed May 13 23:46:08 2026 -0700
yolo tick #27: /api/sources ?sort= (weight/tier/name/slug/kind/enabled, default+fallback)
---
lib/openapi.js | 16 +++++++++++++++-
routes/api.js | 28 ++++++++++++++++++++++++----
tests/smoke.test.js | 36 ++++++++++++++++++++++++++++++++++++
3 files changed, 75 insertions(+), 5 deletions(-)
diff --git a/lib/openapi.js b/lib/openapi.js
index 3ca66f8..d7234d0 100644
--- a/lib/openapi.js
+++ b/lib/openapi.js
@@ -291,13 +291,27 @@ function buildSpec({ version, gitSha }) {
'/api/sources': {
get: {
summary: 'Tiered source registry',
+ description:
+ 'Returns every configured data source (auction houses, marketplaces, ' +
+ 'quote desks, retail) with its tier, kind, weight, and enabled flag. ' +
+ 'Default order is by tier then name. Pass ?sort= to switch — supported ' +
+ 'modes: name, slug, tier, tier-desc, weight-desc, weight-asc, kind, ' +
+ 'enabled. Unknown sort values fall back to the default order.',
tags: ['catalog'],
+ parameters: [{
+ name: 'sort', in: 'query', required: false,
+ schema: { type: 'string' }, example: 'weight-desc'
+ }],
responses: {
200: {
description: 'OK',
content: { 'application/json': { schema: {
type: 'object',
- properties: { sources: { type: 'array', items: { $ref: '#/components/schemas/Source' } } }
+ properties: {
+ count: { type: 'integer' },
+ sort: { type: 'string', nullable: true },
+ sources: { type: 'array', items: { $ref: '#/components/schemas/Source' } }
+ }
} } }
}
}
diff --git a/routes/api.js b/routes/api.js
index 7fa5f61..5f42c0b 100644
--- a/routes/api.js
+++ b/routes/api.js
@@ -120,12 +120,32 @@ router.get('/version', setCache(NO_CACHE), async (req, res, next) => {
} catch (e) { next(e); }
});
+// Whitelist of supported /api/sources ?sort= values mapped to safe SQL
+// ORDER BY fragments. All sort keys are DB-backed columns (no computed
+// values), so SQL-side sort is cleaner than the JS post-sort the other
+// list endpoints use. Default mirrors the historical tier+name order.
+const SOURCE_SORTS = {
+ default: 'tier ASC, name ASC',
+ name: 'name ASC',
+ slug: 'slug ASC',
+ tier: 'tier ASC, name ASC',
+ 'tier-desc': 'tier DESC, name ASC',
+ 'weight-desc':'weight DESC, name ASC',
+ 'weight-asc': 'weight ASC, name ASC',
+ kind: 'kind ASC, name ASC',
+ enabled: 'enabled DESC, tier ASC, name ASC'
+};
+
router.get('/sources', setCache(PUB_300), async (req, res, next) => {
try {
- const r = await pool.query(`SELECT slug, name, tier, kind, weight, enabled, url, notes
- FROM sources
- ORDER BY tier, name`);
- res.json({ sources: r.rows });
+ const sort = typeof req.query.sort === 'string' ? req.query.sort.trim() : '';
+ const orderBy = SOURCE_SORTS[sort] || SOURCE_SORTS.default;
+ const r = await pool.query(
+ `SELECT slug, name, tier, kind, weight, enabled, url, notes
+ FROM sources
+ ORDER BY ${orderBy}`
+ );
+ res.json({ count: r.rows.length, sort: sort || null, sources: r.rows });
} catch (e) { next(e); }
});
diff --git a/tests/smoke.test.js b/tests/smoke.test.js
index 395039b..774b219 100644
--- a/tests/smoke.test.js
+++ b/tests/smoke.test.js
@@ -172,11 +172,47 @@ test('GET /api/sources returns the seeded source registry', async () => {
assert.equal(r.status, 200);
assert.ok(Array.isArray(r.json.sources));
assert.ok(r.json.sources.length >= 4, 'at least four sources seeded');
+ assert.equal(typeof r.json.count, 'number');
+ assert.equal(r.json.count, r.json.sources.length);
+ assert.equal(r.json.sort, null, 'default response has null sort');
const slugs = r.json.sources.map((s) => s.slug);
assert.ok(slugs.includes('sothebys'));
assert.ok(slugs.includes('fashionphile'));
});
+test('GET /api/sources?sort=weight-desc returns highest-weight source first', async () => {
+ const r = await get('/api/sources?sort=weight-desc');
+ assert.equal(r.status, 200);
+ assert.equal(r.json.sort, 'weight-desc');
+ const weights = r.json.sources.map((s) => parseFloat(s.weight));
+ for (let i = 1; i < weights.length; i++) {
+ assert.ok(weights[i - 1] >= weights[i],
+ `weights must be non-increasing — got ${weights.join(',')}`);
+ }
+});
+
+test('GET /api/sources?sort=name returns alphabetical by name', async () => {
+ const r = await get('/api/sources?sort=name');
+ assert.equal(r.status, 200);
+ assert.equal(r.json.sort, 'name');
+ const names = r.json.sources.map((s) => s.name);
+ // Postgres default collation is locale-aware (e.g. 'eBay' < 'FASHIONPHILE')
+ // — use localeCompare so the JS reference matches PG's ORDER BY name.
+ const sorted = names.slice().sort((a, b) => a.localeCompare(b));
+ assert.deepEqual(names, sorted, 'names returned in alphabetical order');
+});
+
+test('GET /api/sources?sort=garbage falls back to default ordering', async () => {
+ const r = await get('/api/sources?sort=garbage');
+ assert.equal(r.status, 200);
+ // Default = tier ASC, name ASC. With seed data, tier-1 (Christie's,
+ // Sotheby's) appear before tier-2/3, sorted by name within tier.
+ assert.equal(r.json.sources[0].tier, 1);
+ // Unknown sort values still echo back the requested string for parity
+ // with the other list endpoints (caller sees what they asked for).
+ assert.equal(r.json.sort, 'garbage');
+});
+
test('GET /api/valuation/birkin-30-togo-gold-ghw-us returns blueprint contract shape', async () => {
const r = await get('/api/valuation/birkin-30-togo-gold-ghw-us');
assert.equal(r.status, 200);
← a2a7ff0 yolo tick #26: /api/index list endpoint with sort + latest-p
·
back to Lifestyle Asset Intel
·
yolo tick #28: /api/family ?q= filter (slug/name/brand, case 5b70e7f →