[object Object]

← 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

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 →