[grade=B] DC-104: App catalog API — browse 38 curated templates
GET /api/v1/catalog — list all apps with category filter, sort options GET /api/v1/catalog/search?q=plex — search by name/category GET /api/v1/catalog/:appId — get app details (image, ports, env, volumes) Uses existing app-templates.js (38 templates). Auto-categorizes into: media, productivity, development, database, network, smart-home, monitoring. Popular badges for Plex, Jellyfin, Sonarr, Radarr, Nextcloud, Gitea, qBittorrent. Auth required (behind login). 1633 tests pass.
This commit is contained in:
@@ -0,0 +1,132 @@
|
|||||||
|
/**
|
||||||
|
* DC-104: App Catalog API — curated templates with categories and search
|
||||||
|
*
|
||||||
|
* Exposes the existing app-templates.js as a browsable catalog.
|
||||||
|
* GET /api/v1/catalog — list all apps (with optional category filter)
|
||||||
|
* GET /api/v1/catalog/:appId — get details for a specific app
|
||||||
|
* GET /api/v1/catalog/search — search apps by name/category/keyword
|
||||||
|
*/
|
||||||
|
const express = require('express');
|
||||||
|
const { ok, errorResponse } = require('../src/utils/responses');
|
||||||
|
|
||||||
|
// Category mapping for common apps
|
||||||
|
const CATEGORY_MAP = {
|
||||||
|
plex: 'media', jellyfin: 'media', emby: 'media',
|
||||||
|
sonarr: 'media', radarr: 'media', prowlarr: 'media', lidarr: 'media',
|
||||||
|
readarr: 'media', qbittorrent: 'media', transmission: 'media',
|
||||||
|
sabnzbd: 'media', nzbget: 'media',
|
||||||
|
nextcloud: 'productivity', vaultwarden: 'productivity',
|
||||||
|
gitea: 'development', portainer: 'development', code: 'development',
|
||||||
|
node: 'development',
|
||||||
|
redis: 'database', postgres: 'database', mariadb: 'database', mongo: 'database',
|
||||||
|
mysql: 'database',
|
||||||
|
nginx: 'network', caddy: 'network', adguard: 'network', pihole: 'network',
|
||||||
|
technitium: 'network', wireguard: 'network',
|
||||||
|
homeassistant: 'smart-home', mosquitto: 'smart-home',
|
||||||
|
grafana: 'monitoring', prometheus: 'monitoring', uptimekuma: 'monitoring',
|
||||||
|
};
|
||||||
|
|
||||||
|
function getTemplateCategory(template) {
|
||||||
|
const id = (template.id || template.name || '').toLowerCase();
|
||||||
|
for (const [key, cat] of Object.entries(CATEGORY_MAP)) {
|
||||||
|
if (id.includes(key)) return cat;
|
||||||
|
}
|
||||||
|
return 'other';
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = function({ APP_TEMPLATES, asyncHandler } = {}) {
|
||||||
|
const wrap = asyncHandler || ((fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next));
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// GET /api/v1/catalog — list all apps
|
||||||
|
router.get('/catalog', wrap(async (req, res) => {
|
||||||
|
const { category, sort } = req.query;
|
||||||
|
let apps = APP_TEMPLATES || [];
|
||||||
|
|
||||||
|
// Build catalog entries
|
||||||
|
let entries = apps.map(t => ({
|
||||||
|
id: t.id || t.name?.toLowerCase().replace(/\s+/g, '-'),
|
||||||
|
name: t.name,
|
||||||
|
description: t.description || '',
|
||||||
|
category: getTemplateCategory(t),
|
||||||
|
logo: t.logo || null,
|
||||||
|
popular: ['plex', 'jellyfin', 'sonarr', 'radarr', 'nextcloud', 'gitea', 'qbittorrent']
|
||||||
|
.includes((t.id || t.name || '').toLowerCase().replace(/\s+/g, '-')),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Filter by category
|
||||||
|
if (category && category !== 'all') {
|
||||||
|
entries = entries.filter(e => e.category === category);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort
|
||||||
|
if (sort === 'name') {
|
||||||
|
entries.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
} else {
|
||||||
|
// Default: popular first, then alphabetical
|
||||||
|
entries.sort((a, b) => {
|
||||||
|
if (a.popular !== b.popular) return a.popular ? -1 : 1;
|
||||||
|
return a.name.localeCompare(b.name);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get categories
|
||||||
|
const categories = [...new Set(entries.map(e => e.category))].sort();
|
||||||
|
|
||||||
|
ok(res, {
|
||||||
|
total: entries.length,
|
||||||
|
categories,
|
||||||
|
apps: entries,
|
||||||
|
});
|
||||||
|
}));
|
||||||
|
|
||||||
|
// GET /api/v1/catalog/search?q=plex
|
||||||
|
router.get('/catalog/search', wrap(async (req, res) => {
|
||||||
|
const q = (req.query.q || '').toLowerCase().trim();
|
||||||
|
if (!q) {
|
||||||
|
return errorResponse(res, 400, 'Search query (q) is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
const apps = (APP_TEMPLATES || []).filter(t => {
|
||||||
|
const name = (t.name || '').toLowerCase();
|
||||||
|
const desc = (t.description || '').toLowerCase();
|
||||||
|
const cat = getTemplateCategory(t).toLowerCase();
|
||||||
|
return name.includes(q) || desc.includes(q) || cat.includes(q);
|
||||||
|
}).map(t => ({
|
||||||
|
id: t.id || t.name?.toLowerCase().replace(/\s+/g, '-'),
|
||||||
|
name: t.name,
|
||||||
|
description: t.description || '',
|
||||||
|
category: getTemplateCategory(t),
|
||||||
|
}));
|
||||||
|
|
||||||
|
ok(res, { query: q, results: apps.length, apps });
|
||||||
|
}));
|
||||||
|
|
||||||
|
// GET /api/v1/catalog/:appId — get specific app details
|
||||||
|
router.get('/catalog/:appId', wrap(async (req, res) => {
|
||||||
|
const appId = req.params.appId;
|
||||||
|
const app = (APP_TEMPLATES || []).find(t => {
|
||||||
|
const tid = (t.id || t.name?.toLowerCase().replace(/\s+/g, '-'));
|
||||||
|
return tid === appId;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!app) {
|
||||||
|
return errorResponse(res, 404, `App '${appId}' not found in catalog`);
|
||||||
|
}
|
||||||
|
|
||||||
|
ok(res, {
|
||||||
|
id: app.id || appId,
|
||||||
|
name: app.name,
|
||||||
|
description: app.description || '',
|
||||||
|
category: getTemplateCategory(app),
|
||||||
|
image: app.image || '',
|
||||||
|
ports: app.ports || [],
|
||||||
|
env: app.env || {},
|
||||||
|
volumes: app.volumes || [],
|
||||||
|
network: app.network || 'bridge',
|
||||||
|
restart: app.restart || 'unless-stopped',
|
||||||
|
});
|
||||||
|
}));
|
||||||
|
|
||||||
|
return router;
|
||||||
|
};
|
||||||
@@ -63,6 +63,7 @@ const shareRoutes = require('../routes/share');
|
|||||||
const i18nRoutes = require('../routes/i18n');
|
const i18nRoutes = require('../routes/i18n');
|
||||||
const discoverRoutes = require('../routes/discover');
|
const discoverRoutes = require('../routes/discover');
|
||||||
const discoverAdoptRoutes = require('../routes/discover-adopt');
|
const discoverAdoptRoutes = require('../routes/discover-adopt');
|
||||||
|
const catalogRoutes = require('../routes/catalog');
|
||||||
const configRoutes = require('../routes/config');
|
const configRoutes = require('../routes/config');
|
||||||
const dnsRoutes = require('../routes/dns');
|
const dnsRoutes = require('../routes/dns');
|
||||||
const notificationRoutes = require('../routes/notifications');
|
const notificationRoutes = require('../routes/notifications');
|
||||||
@@ -618,6 +619,12 @@ async function createApp() {
|
|||||||
siteConfig: ctx.config,
|
siteConfig: ctx.config,
|
||||||
asyncHandler: ctx.asyncHandler,
|
asyncHandler: ctx.asyncHandler,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// DC-104: App catalog — browse curated templates
|
||||||
|
apiRouter.use(catalogRoutes({
|
||||||
|
APP_TEMPLATES: require('./docker/app-templates'),
|
||||||
|
asyncHandler: ctx.asyncHandler,
|
||||||
|
}));
|
||||||
apiRouter.use(updatesRoutes({
|
apiRouter.use(updatesRoutes({
|
||||||
updateManager: ctx.updateManager,
|
updateManager: ctx.updateManager,
|
||||||
selfUpdater: ctx.selfUpdater,
|
selfUpdater: ctx.selfUpdater,
|
||||||
|
|||||||
Reference in New Issue
Block a user