From 2d9a90a5817e22f0558c6e5d8099cbfbf8c8bec3 Mon Sep 17 00:00:00 2001 From: Georgios Voulgaris Date: Tue, 16 Dec 2025 17:23:36 +0100 Subject: [PATCH 1/4] Implemented API: 4.8 Make response of GET /collections and GET /collections/{id} STAC-Conform STAC API Validator - Conformance Validation Complete Successfully ran the STAC API Validator locally against the API and confirmed full conformance with both Core and Collections specifications. 1. stac_extensions field - Changed from null to empty array [] (STAC spec requires array type) Updated: routes/collections.js 2.Collection type field - Added type: 'Collection' to all in-memory test collections Updated: data/collections.js 3. String IDs - Converted numeric database IDs to strings (STAC requires string IDs) Updated: routes/collections.js 4. Provider roles as arrays - Changed from comma-separated string to proper array Updated: buildCollectionSearchQuery.js using string_to_array() 5. Remove null optional fields - Assets and summaries now omitted if null (not included as null) Updated: routes/collections.js 6. Test updated - Fixed test expectation for provider roles SQL pattern Updated: buildCollectionSearchQuery.aggregates.test.js --- .github/workflows/api-ci.yml | 106 +++++++- api/.env.example | 31 --- api/__tests__/api.test.js | 45 +++- ...ldCollectionSearchQuery.aggregates.test.js | 2 +- api/__tests__/collectionSearch.test.js | 6 +- api/data/collections.js | 47 +++- api/db/buildCollectionSearchQuery.js | 7 +- api/db/db_APIconnection.js | 20 +- api/routes/collections.js | 249 ++++++++++++++---- db/init/06_seed_data.sql | 190 +++++++++++++ 10 files changed, 602 insertions(+), 101 deletions(-) delete mode 100644 api/.env.example create mode 100644 db/init/06_seed_data.sql diff --git a/.github/workflows/api-ci.yml b/.github/workflows/api-ci.yml index 5394698..03407ce 100644 --- a/.github/workflows/api-ci.yml +++ b/.github/workflows/api-ci.yml @@ -199,12 +199,114 @@ jobs: cd api npm audit --audit-level=moderate continue-on-error: true + + # Job 4: STAC API Validator (Core + Collections) + stac-api-validator: + name: STAC API Validator + runs-on: ubuntu-latest + needs: [test, build] + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22.x + cache: 'npm' + cache-dependency-path: api/package-lock.json + + - name: Create .env file + working-directory: api + run: | + cat > .env << EOF + # Server Configuration + PORT=3000 + NODE_ENV=test + + # Database Configuration + DB_HOST=${{ secrets.DB_HOST }} + DB_PORT=${{ secrets.DB_PORT }} + DB_NAME=${{ secrets.DB_NAME }} + DB_USER=${{ secrets.DB_USER }} + DB_PASSWORD=${{ secrets.DB_PASSWORD }} + DB_SSL=false + + # Connection Pool Configuration + DB_POOL_MAX=20 + DB_POOL_MIN=2 + DB_IDLE_TIMEOUT=30000 + DB_CONNECTION_TIMEOUT=10000 + + # CORS Configuration + CORS_ORIGIN=* + + # API Configuration + API_TITLE=STAC Atlas + API_DESCRIPTION=A centralized platform for managing, indexing, and providing STAC Collection metadata + API_VERSION=1.1.0 + EOF + + - name: Install dependencies + run: | + cd api + npm ci + + - name: Start API server + working-directory: api + run: | + npm start > $GITHUB_WORKSPACE/api/api-server.log 2>&1 & + echo $! > $GITHUB_WORKSPACE/api/api-server.pid + + - name: Wait for API to be ready + run: | + for i in {1..12}; do + if curl -fsS http://localhost:3000/collections > /dev/null; then + exit 0 + fi + sleep 5 + done + echo "API did not become ready in time" >&2 + exit 1 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install STAC API Validator + run: | + python -m pip install --upgrade pip + python -m pip install stac-api-validator + + - name: Run STAC API Validator (core + collections) + run: | + python -m stac_api_validator \ + --root-url http://localhost:3000 \ + --conformance core \ + --conformance collections \ + --collection sentinel-2-l2a + + - name: Kill API server + if: always() + run: | + if [ -f "$GITHUB_WORKSPACE/api/api-server.pid" ]; then + kill "$(cat $GITHUB_WORKSPACE/api/api-server.pid)" || true + fi + + - name: Upload API server log + if: always() + uses: actions/upload-artifact@v4 + with: + name: stac-api-server-log + path: api/api-server.log + retention-days: 7 - # Job 4: Status-Check for Branch Protection + # Job 5: Status-Check for Branch Protection ci-success: name: CI Success runs-on: ubuntu-latest - needs: [test, build] + needs: [test, build, stac-api-validator] if: always() steps: diff --git a/api/.env.example b/api/.env.example deleted file mode 100644 index 5906869..0000000 --- a/api/.env.example +++ /dev/null @@ -1,31 +0,0 @@ -# Server Configuration -PORT=3000 -NODE_ENV=development - - -# Database Configuration (Debian Server) -# Option 1: Use DATABASE_URL (PostgreSQL connection string) -# add DB_USER and DB_PASSWORD values -DATABASE_URL= postgresql://[**DB_USER**]:[**DB_PASSWORD**]@atlas.stacindex.org:5432/stac_db - -# Option 2: Use individual variables (currently active) -DB_HOST=atlas.stacindex.org -DB_PORT=5433 # 5432 for production -DB_NAME=stac_db -DB_USER= -DB_PASSWORD= -DB_SSL=false - -# Connection Pool Configuration -DB_POOL_MAX=20 -DB_POOL_MIN=2 -DB_IDLE_TIMEOUT=30000 -DB_CONNECTION_TIMEOUT=10000 - -# CORS Configuration -CORS_ORIGIN=* - -# API Configuration -API_TITLE=STAC Atlas -API_DESCRIPTION=A centralized platform for managing, indexing, and providing STAC Collection metadata -API_VERSION=1.1.0 diff --git a/api/__tests__/api.test.js b/api/__tests__/api.test.js index 6c77a1f..7874c2a 100644 --- a/api/__tests__/api.test.js +++ b/api/__tests__/api.test.js @@ -71,10 +71,10 @@ describe('STAC API Core Endpoints', () => { }); describe('GET /collections', () => { - it('should return a FeatureCollection structure', async () => { + it('should return a Collections structure', async () => { const response = await request(app).get('/collections').expect(200); - - expect(response.body).toHaveProperty('type', 'FeatureCollection'); + + // expect(response.body).toHaveProperty('type', 'FeatureCollection'); (not sure if needed because some test fail) expect(response.body).toHaveProperty('collections'); expect(response.body).toHaveProperty('links'); expect(response.body).toHaveProperty('context'); @@ -88,6 +88,22 @@ describe('STAC API Core Endpoints', () => { expect(response.body.context).toHaveProperty('limit'); expect(response.body.context).toHaveProperty('matched'); }); + + it('should return STAC Collection objects with required fields', async () => { + const response = await request(app).get('/collections').expect(200); + + if (response.body.collections.length > 0) { + const collection = response.body.collections[0]; + expect(collection).toHaveProperty('id'); + expect(collection).toHaveProperty('stac_version'); + expect(collection).toHaveProperty('title'); + expect(collection).toHaveProperty('description'); + expect(collection).toHaveProperty('license'); + expect(collection).toHaveProperty('extent'); + expect(collection).toHaveProperty('links'); + expect(Array.isArray(collection.links)).toBe(true); + } + }); }); describe('GET /queryables', () => { @@ -128,5 +144,28 @@ describe('STAC API Core Endpoints', () => { expect(response.body).toHaveProperty('description'); expect(response.body).toHaveProperty('id', 'non-existent-id'); }); + + it('should return STAC Collection object with required fields', async () => { + const response = await request(app).get('/collections/sentinel-2-l2a').expect(200); + + expect(response.body).toHaveProperty('id', 'sentinel-2-l2a'); + expect(response.body).toHaveProperty('stac_version'); + expect(response.body).toHaveProperty('title'); + expect(response.body).toHaveProperty('description'); + expect(response.body).toHaveProperty('license'); + expect(response.body).toHaveProperty('extent'); + expect(response.body).toHaveProperty('links'); + expect(Array.isArray(response.body.links)).toBe(true); + }); + + it('should include self and root links', async () => { + const response = await request(app).get('/collections/sentinel-2-l2a').expect(200); + + const links = response.body.links; + const linkRels = links.map(link => link.rel); + + expect(linkRels).toContain('self'); + expect(linkRels).toContain('root'); + }); }); }); diff --git a/api/__tests__/buildCollectionSearchQuery.aggregates.test.js b/api/__tests__/buildCollectionSearchQuery.aggregates.test.js index 069ea46..cb7ea9e 100644 --- a/api/__tests__/buildCollectionSearchQuery.aggregates.test.js +++ b/api/__tests__/buildCollectionSearchQuery.aggregates.test.js @@ -64,7 +64,7 @@ describe('buildCollectionSearchQuery - aggregated fields', () => { expect(sql).toMatch(/jsonb_agg\(jsonb_build_object\(/); expect(sql).toMatch(/'name', p\.provider/); - expect(sql).toMatch(/'roles', cpr\.collection_provider_roles/); + expect(sql).toMatch(/'roles', string_to_array\(cpr\.collection_provider_roles, ','\)/); expect(sql).toMatch(/FROM collection_providers cpr/); expect(sql).toMatch(/JOIN providers p ON p\.id = cpr\.provider_id/); expect(sql).toMatch(/WHERE cpr\.collection_id = c\.id/); diff --git a/api/__tests__/collectionSearch.test.js b/api/__tests__/collectionSearch.test.js index 25c16c5..d4514f8 100644 --- a/api/__tests__/collectionSearch.test.js +++ b/api/__tests__/collectionSearch.test.js @@ -13,8 +13,8 @@ describe('Collection Search API - Query Parameters', () => { const response = await request(app) .get('/collections') .expect(200); - - expect(response.body).toHaveProperty('type', 'FeatureCollection'); + + // expect(response.body).toHaveProperty('type', 'FeatureCollection') (not sure if needed because some test fail) expect(response.body).toHaveProperty('collections'); expect(response.body).toHaveProperty('context'); expect(response.body.context.limit).toBe(10); // default limit @@ -366,7 +366,7 @@ describe('Collection Search API - Query Parameters', () => { .expect(200); expect(response.body).toMatchObject({ - type: 'FeatureCollection', + //type: 'FeatureCollection', (not sure if needed because some test fail) collections: expect.any(Array), links: expect.any(Array), context: { diff --git a/api/data/collections.js b/api/data/collections.js index e58bcbf..49e037c 100644 --- a/api/data/collections.js +++ b/api/data/collections.js @@ -14,6 +14,8 @@ module.exports = [ description: 'Sentinel-2 Level-2A processed imagery from Copernicus', keywords: ['sentinel-2', 'optical', 'multispectral'], license: 'CC-BY-4.0', + created: '2018-01-01T00:00:00Z', + updated: '2025-01-01T00:00:00Z', providers: [ { name: 'ESA', @@ -25,16 +27,26 @@ module.exports = [ spatial: { bbox: [[-180, -90, 180, 90]] }, temporal: { interval: [['2015-06-23T00:00:00Z', null]] } }, + summaries: { + 'eo:bands': [ + { name: 'B2', common_name: 'blue' }, + { name: 'B3', common_name: 'green' }, + { name: 'B4', common_name: 'red' }, + { name: 'B5', common_name: 'nir' } + ] + }, links: [ { rel: 'self', href: 'https://example.com/collections/sentinel-2-l2a', - type: 'application/json' + type: 'application/json', + title: 'Sentinel-2 L2A Collection' }, { rel: 'parent', href: 'https://example.com/', - type: 'application/json' + type: 'application/json', + title: 'Parent' } ] }, @@ -46,6 +58,8 @@ module.exports = [ description: 'Landsat 8 Collection 1 Level 1 data', keywords: ['landsat', 'optical', 'multispectral'], license: 'CC0-1.0', + created: '2013-02-11T00:00:00Z', + updated: '2024-06-01T00:00:00Z', providers: [ { name: 'USGS', @@ -57,16 +71,26 @@ module.exports = [ spatial: { bbox: [[-180, -90, 180, 90]] }, temporal: { interval: [['2013-02-11T00:00:00Z', null]] } }, + summaries: { + 'eo:bands': [ + { name: 'B1', common_name: 'coastal' }, + { name: 'B2', common_name: 'blue' }, + { name: 'B3', common_name: 'green' }, + { name: 'B4', common_name: 'red' } + ] + }, links: [ { rel: 'self', href: 'https://example.com/collections/landsat-8-l1', - type: 'application/json' + type: 'application/json', + title: 'Landsat 8 Level-1' }, { rel: 'parent', href: 'https://example.com/', - type: 'application/json' + type: 'application/json', + title: 'Parent' } ] }, @@ -78,6 +102,8 @@ module.exports = [ description: 'MODIS daily composites from NASA Earth Observatories', keywords: ['modis', 'daily', 'thermal', 'visible'], license: 'CC0-1.0', + created: '2000-02-24T00:00:00Z', + updated: '2023-12-31T00:00:00Z', providers: [ { name: 'NASA', @@ -89,16 +115,25 @@ module.exports = [ spatial: { bbox: [[-180, -90, 180, 90]] }, temporal: { interval: [['2000-02-24T00:00:00Z', null]] } }, + summaries: { + 'eo:bands': [ + { name: '1', common_name: 'red' }, + { name: '2', common_name: 'nir' }, + { name: '31', common_name: 'thermal' } + ] + }, links: [ { rel: 'self', href: 'https://example.com/collections/modis', - type: 'application/json' + type: 'application/json', + title: 'MODIS Daily' }, { rel: 'parent', href: 'https://example.com/', - type: 'application/json' + type: 'application/json', + title: 'Parent' } ] } diff --git a/api/db/buildCollectionSearchQuery.js b/api/db/buildCollectionSearchQuery.js index 8d43cee..b8eb94c 100644 --- a/api/db/buildCollectionSearchQuery.js +++ b/api/db/buildCollectionSearchQuery.js @@ -217,7 +217,7 @@ function buildCollectionSearchQuery(params) { LEFT JOIN LATERAL ( SELECT jsonb_agg(jsonb_build_object( 'name', p.provider, - 'roles', cpr.collection_provider_roles + 'roles', string_to_array(cpr.collection_provider_roles, ',') ) ORDER BY p.provider) AS providers FROM collection_providers cpr JOIN providers p ON p.id = cpr.provider_id @@ -272,7 +272,10 @@ function buildCollectionSearchQuery(params) { // Note: sortby.field is validated against a whitelist in the calling code; only collection // table columns are allowed for sorting (not aggregated fields like keywords/providers). if (sortby) { - sql += ` ORDER BY c.${sortby.field} ${sortby.direction}`; + const sortField = sortby.field; + const dir = sortby.direction; + const collate = sortField === 'license' ? ' COLLATE "C"' : ''; + sql += ` ORDER BY c.${sortField}${collate} ${dir}, c.id ASC`; } else if (q) { sql += ` ORDER BY rank DESC, c.id ASC`; } else { diff --git a/api/db/db_APIconnection.js b/api/db/db_APIconnection.js index b3a93fc..1cfd0bb 100644 --- a/api/db/db_APIconnection.js +++ b/api/db/db_APIconnection.js @@ -1,5 +1,7 @@ const { Pool } = require('pg'); -require('dotenv').config(); +require('dotenv').config({ override: true }); + +const IS_TEST = process.env.NODE_ENV === 'test' || process.env.JEST_WORKER_ID !== undefined; // PostgreSQL/PostGIS database connection // Support both DATABASE_URL and individual environment variables @@ -46,7 +48,7 @@ pool.on('error', (err) => { }); // Handle pool connection events for monitoring (only in non-test environments) -if (process.env.NODE_ENV !== 'test') { +if (!IS_TEST) { pool.on('connect', (client) => { console.log('New client connected to pool'); }); @@ -107,16 +109,18 @@ async function testConnection(retries = 3, delay = 2000) { waitingCount: pool.waitingCount }; - console.log('✓ Database connection successful'); - console.log(` Database: ${result.rows[0].database}`); - console.log(` PostgreSQL version: ${result.rows[0].version.split(',')[0]}`); - console.log(` Pool status: ${poolInfo.totalCount} total, ${poolInfo.idleCount} idle, ${poolInfo.waitingCount} waiting`); + if (!IS_TEST) { + console.log('✓ Database connection successful'); + console.log(` Database: ${result.rows[0].database}`); + console.log(` PostgreSQL version: ${result.rows[0].version.split(',')[0]}`); + console.log(` Pool status: ${poolInfo.totalCount} total, ${poolInfo.idleCount} idle, ${poolInfo.waitingCount} waiting`); + } return true; } catch (error) { console.error(`✗ Connection attempt ${i + 1}/${retries} failed:`, error.message); if (i < retries - 1) { - console.log(` Retrying in ${delay / 1000} seconds...`); + if (!IS_TEST) console.log(` Retrying in ${delay / 1000} seconds...`); await new Promise(resolve => setTimeout(resolve, delay)); } } @@ -247,7 +251,7 @@ async function queryByDistance(table, point, distance, geomColumn = 'spatial_ext async function closePool() { try { await pool.end(); - console.log('✓ Database connection pool closed'); + if (!IS_TEST) console.log('✓ Database connection pool closed'); } catch (error) { console.error('Error closing database pool:', error.message); throw error; diff --git a/api/routes/collections.js b/api/routes/collections.js index 6f83a2f..565b342 100644 --- a/api/routes/collections.js +++ b/api/routes/collections.js @@ -38,39 +38,123 @@ router.get('/', validateCollectionSearchParams, async (req, res, next) => { // validated parameters from middleware const { q, bbox, datetime, limit, sortby, token } = req.validatedParams; - // build SQL querry and parameters - const { sql, values } = buildCollectionSearchQuery({ - q, - bbox, - datetime, - limit, - sortby, - token - }); + // try to use database + let collections; + try { + // build SQL querry and parameters + const { sql, values } = buildCollectionSearchQuery({ + q, + bbox, + datetime, + limit, + sortby, + token + }); + + // execute Query against database + collections = await runQuery(sql, values); + + // Shape DB rows to STAC structure if needed (add extent) + collections = collections.map(row => { + if (row && (row.extent || (!row.spatial_extend && !row.temporal_extend_start && !row.temporal_extend_end))) { + return row; + } + + const extent = {}; + if (row.spatial_extend) { + // We set a world bbox here; adjust if precise bbox is required later + extent.spatial = { bbox: [[-180, -90, 180, 90]] }; + } + if (row.temporal_extend_start || row.temporal_extend_end) { + const start = row.temporal_extend_start ? new Date(row.temporal_extend_start).toISOString() : null; + const end = row.temporal_extend_end ? new Date(row.temporal_extend_end).toISOString() : null; + extent.temporal = { interval: [[start, end]] }; + } + return Object.assign({}, row, { extent }); + }); + } catch (dbError) { + // Fallback to in-memory data store if database is not available + console.warn('Database query failed, using in-memory data store:', dbError.message); + collections = collectionsStore; + + // Apply basic filtering to in-memory data + if (q) { + const qLower = q.toLowerCase(); + collections = collections.filter(c => + (c.title && c.title.toLowerCase().includes(qLower)) || + (c.description && c.description.toLowerCase().includes(qLower)) || + (c.keywords && c.keywords.some(k => k.toLowerCase().includes(qLower))) + ); + } + + if (sortby) { + // sortby is normalized by validator to { field: , direction: 'ASC'|'DESC' } + const dbField = sortby.field; + const direction = sortby.direction; + + // Map DB field names to in-memory keys + const inMemoryFieldMap = { + id: 'id', + title: 'title', + license: 'license', + created_at: 'created', + updated_at: 'updated' + }; + + const fieldKey = inMemoryFieldMap[dbField] || dbField; + + collections = [...collections].sort((a, b) => { + const aVal = a[fieldKey] ?? ''; + const bVal = b[fieldKey] ?? ''; - // execute Query against database - const collections = await runQuery(sql, values); + // Date-aware compare for created/updated + const isDateField = fieldKey === 'created' || fieldKey === 'updated'; + let comparison; + if (isDateField) { + const aTime = aVal ? new Date(aVal).getTime() : 0; + const bTime = bVal ? new Date(bVal).getTime() : 0; + comparison = aTime === bTime ? 0 : (aTime < bTime ? -1 : 1); + } else { + comparison = String(aVal).localeCompare(String(bVal)); + } + + return direction === 'DESC' ? -comparison : comparison; + }); + } + + // Apply pagination + const start = token || 0; + collections = collections.slice(start, start + limit); + } + const returned = collections.length; // Get total count for matched field - // Build count query using same WHERE conditions - const { sql: countSql, values: countValues } = buildCollectionSearchQuery({ - q, - bbox, - datetime, - limit: null, // No limit for count - sortby: null, // No sorting for count - token: null // No offset for count - }); - - // Replace SELECT with COUNT(*) - const countQuery = countSql - .replace(/SELECT[\s\S]*?FROM/, 'SELECT COUNT(*) as total FROM') - .replace(/ORDER BY.*$/, '') - .replace(/LIMIT.*$/, ''); - - const countResult = await runQuery(countQuery, countValues); - const matched = parseInt(countResult[0]?.total || 0); + let matched; + try { + // Build count query using same WHERE conditions + const { sql: countSql, values: countValues } = buildCollectionSearchQuery({ + q, + bbox, + datetime, + limit: null, // No limit for count + sortby: null, // No sorting for count + token: null // No offset for count + }); + + // Replace SELECT with COUNT(*) + const countQuery = countSql + .replace(/SELECT[\s\S]*?FROM/, 'SELECT COUNT(*) as total FROM') + .replace(/ORDER BY.*$/, '') + .replace(/LIMIT.*$/, ''); + + const countResult = await runQuery(countQuery, countValues); + matched = parseInt(countResult[0]?.total || 0); + } catch (countError) { + // Fallback to in-memory count + console.warn('Count query failed, using in-memory count:', countError.message); + matched = collectionsStore.length; + } // Base URL for links const baseHost = `${req.protocol}://${req.get('host')}`; @@ -82,8 +166,15 @@ router.get('/', validateCollectionSearchParams, async (req, res, next) => { type: 'application/json' }); + // self link should mirror the requested URL (no synthesized query params) + const selfLink = { + rel: 'self', + href: `${baseHost}${req.originalUrl || req.baseUrl}`, + type: 'application/json' + }; + const links = [ - buildLink('self', token), + selfLink, { rel: 'root', href: baseHost, @@ -102,9 +193,55 @@ router.get('/', validateCollectionSearchParams, async (req, res, next) => { links.push(buildLink('prev', prevToken)); } + // Add required links to each collection if not present + const enrichedCollections = collections.map(collection => { + const colSelfHref = `${baseHost}/collections/${collection.id}`; + const rootHref = baseHost; + + const existingLinks = Array.isArray(collection.links) ? collection.links.slice() : []; + const filteredLinks = existingLinks.filter(l => !l || !['self', 'root', 'parent'].includes(l.rel)); + + filteredLinks.push({ + rel: 'self', + href: colSelfHref, + type: 'application/json', + title: collection.title || collection.id + }); + + filteredLinks.push({ + rel: 'root', + href: rootHref, + type: 'application/json', + title: 'STAC Atlas' + }); + + filteredLinks.push({ + rel: 'parent', + href: rootHref, + type: 'application/json', + title: 'Parent' + }); + + // Ensure stac_extensions is an array (STAC spec requires array, not null) + const stac_extensions = Array.isArray(collection.stac_extensions) + ? collection.stac_extensions + : []; + + // Ensure id is a string (STAC spec requires string IDs) + const id = typeof collection.id === 'string' ? collection.id : String(collection.id); + + // Remove null optional fields or convert to correct type for STAC compliance + const cleaned = Object.assign({}, collection, { id, links: filteredLinks, stac_extensions }); + + // Remove null assets, summaries (optional in STAC, should be omitted if not present) + if (cleaned.assets === null) delete cleaned.assets; + if (cleaned.summaries === null) delete cleaned.summaries; + + return cleaned; + }); + res.json({ - type: 'FeatureCollection', - collections, + collections: enrichedCollections, links, context: { returned, @@ -151,24 +288,46 @@ router.get('/:id', (req, res) => { const rootHref = baseHost; const existingLinks = Array.isArray(collection.links) ? collection.links.slice() : []; + const filteredLinks = existingLinks.filter(l => !l || !['self', 'root', 'parent'].includes(l.rel)); - const hasRel = (rel) => existingLinks.some(l => l && l.rel === rel); + filteredLinks.push({ + rel: 'self', + href: selfHref, + type: 'application/json', + title: collection.title || collection.id + }); - if (!hasRel('self')) { - existingLinks.push({ rel: 'self', href: selfHref, type: 'application/json' }); - } + filteredLinks.push({ + rel: 'root', + href: rootHref, + type: 'application/json', + title: 'STAC Atlas' + }); - if (!hasRel('root')) { - existingLinks.push({ rel: 'root', href: rootHref, type: 'application/json' }); - } + filteredLinks.push({ + rel: 'parent', + href: rootHref, + type: 'application/json', + title: 'Parent' + }); - // Prefer an existing parent link if present, otherwise fall back to root - if (!hasRel('parent')) { - existingLinks.push({ rel: 'parent', href: rootHref, type: 'application/json' }); - } + // Ensure stac_extensions is an array (STAC spec requires array, not null) + const stac_extensions = Array.isArray(collection.stac_extensions) + ? collection.stac_extensions + : []; + + // Ensure id is a string (STAC spec requires string IDs) + const collectionId = typeof collection.id === 'string' ? collection.id : String(collection.id); + + // Build response and remove null optional fields for STAC compliance + const result = Object.assign({}, collection, { id: collectionId, links: filteredLinks, stac_extensions }); + + // Remove null assets, summaries (optional in STAC, should be omitted if not present) + if (result.assets === null) delete result.assets; + if (result.summaries === null) delete result.summaries; - // Return the collection with a normalized `links` array - res.json(Object.assign({}, collection, { links: existingLinks })); + // Return the collection with a normalized `links` array and stac_extensions + res.json(result); }); module.exports = router; diff --git a/db/init/06_seed_data.sql b/db/init/06_seed_data.sql new file mode 100644 index 0000000..209deaa --- /dev/null +++ b/db/init/06_seed_data.sql @@ -0,0 +1,190 @@ +-- I did not wrote this code myself. It was generated by ChatGPT based on my instructions. It needs to be reviewed and tested. + + +-- Seed data for collections, keywords, and providers (idempotent) +-- This script can be executed repeatedly without creating duplicates. + +-- Root catalog (optional) +INSERT INTO catalog (stac_version, type, title, description) +SELECT '1.0.0', 'Catalog', 'STAC Atlas Root', 'Root STAC catalog for tests' +WHERE NOT EXISTS ( + SELECT 1 FROM catalog WHERE title = 'STAC Atlas Root' +); + +-- Keywords +INSERT INTO keywords (keyword) VALUES + ('sentinel-2'), + ('optical'), + ('multispectral'), + ('landsat'), + ('modis'), + ('daily'), + ('thermal'), + ('visible') +ON CONFLICT (keyword) DO NOTHING; + +-- Providers +INSERT INTO providers (provider) VALUES + ('ESA'), + ('USGS'), + ('NASA') +ON CONFLICT (provider) DO NOTHING; + +-- Sentinel-2 L2A +DO $$ +DECLARE + v_collection_id INTEGER; + v_provider_id INTEGER; +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM collection WHERE title = 'Sentinel-2 L2A Collection' + ) THEN + INSERT INTO collection ( + stac_version, type, title, description, license, + spatial_extend, temporal_extend_start, temporal_extend_end, + is_api, is_active, full_json, created_at, updated_at + ) VALUES ( + '1.0.0', 'Collection', 'Sentinel-2 L2A Collection', + 'Sentinel-2 Level-2A processed imagery from Copernicus', 'CC-BY-4.0', + ST_MakeEnvelope(-180, -90, 180, 90, 4326), + '2015-06-23T00:00:00Z'::timestamp, '2025-01-01T00:00:00Z'::timestamp, + TRUE, TRUE, + jsonb_build_object( + 'id','sentinel-2-l2a', + 'stac_version','1.0.0', + 'title','Sentinel-2 L2A Collection' + ), + '2018-01-01T00:00:00Z'::timestamp, + '2025-01-01T00:00:00Z'::timestamp + ); + END IF; + + SELECT id INTO v_collection_id FROM collection WHERE title = 'Sentinel-2 L2A Collection' ORDER BY id DESC LIMIT 1; + + -- Ensure temporal end is set if previously null + UPDATE collection + SET temporal_extend_end = updated_at + WHERE id = v_collection_id AND temporal_extend_end IS NULL; + + INSERT INTO collection_keywords (collection_id, keyword_id) + SELECT v_collection_id, k.id FROM keywords k + WHERE k.keyword IN ('sentinel-2','optical','multispectral') + AND NOT EXISTS ( + SELECT 1 FROM collection_keywords ck WHERE ck.collection_id = v_collection_id AND ck.keyword_id = k.id + ); + + SELECT id INTO v_provider_id FROM providers WHERE provider = 'ESA'; + IF v_provider_id IS NOT NULL THEN + INSERT INTO collection_providers (collection_id, provider_id, collection_provider_roles) + SELECT v_collection_id, v_provider_id, 'producer,licensor' + WHERE NOT EXISTS ( + SELECT 1 FROM collection_providers cp WHERE cp.collection_id = v_collection_id AND cp.provider_id = v_provider_id + ); + END IF; +END $$; + +-- Landsat 8 Level-1 +DO $$ +DECLARE + v_collection_id INTEGER; + v_provider_id INTEGER; +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM collection WHERE title = 'Landsat 8 Level-1' + ) THEN + INSERT INTO collection ( + stac_version, type, title, description, license, + spatial_extend, temporal_extend_start, temporal_extend_end, + is_api, is_active, full_json, created_at, updated_at + ) VALUES ( + '1.0.0', 'Collection', 'Landsat 8 Level-1', + 'Landsat 8 Collection 1 Level 1 data', 'CC0-1.0', + ST_MakeEnvelope(-180, -90, 180, 90, 4326), + '2013-02-11T00:00:00Z'::timestamp, '2024-06-01T00:00:00Z'::timestamp, + TRUE, TRUE, + jsonb_build_object( + 'id','landsat-8-l1', + 'stac_version','1.0.0', + 'title','Landsat 8 Level-1' + ), + '2013-02-11T00:00:00Z'::timestamp, + '2024-06-01T00:00:00Z'::timestamp + ); + END IF; + + SELECT id INTO v_collection_id FROM collection WHERE title = 'Landsat 8 Level-1' ORDER BY id DESC LIMIT 1; + + -- Ensure temporal end is set if previously null + UPDATE collection + SET temporal_extend_end = updated_at + WHERE id = v_collection_id AND temporal_extend_end IS NULL; + + INSERT INTO collection_keywords (collection_id, keyword_id) + SELECT v_collection_id, k.id FROM keywords k + WHERE k.keyword IN ('landsat','optical','multispectral') + AND NOT EXISTS ( + SELECT 1 FROM collection_keywords ck WHERE ck.collection_id = v_collection_id AND ck.keyword_id = k.id + ); + + SELECT id INTO v_provider_id FROM providers WHERE provider = 'USGS'; + IF v_provider_id IS NOT NULL THEN + INSERT INTO collection_providers (collection_id, provider_id, collection_provider_roles) + SELECT v_collection_id, v_provider_id, 'producer' + WHERE NOT EXISTS ( + SELECT 1 FROM collection_providers cp WHERE cp.collection_id = v_collection_id AND cp.provider_id = v_provider_id + ); + END IF; +END $$; + +-- MODIS Daily +DO $$ +DECLARE + v_collection_id INTEGER; + v_provider_id INTEGER; +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM collection WHERE title = 'MODIS Daily' + ) THEN + INSERT INTO collection ( + stac_version, type, title, description, license, + spatial_extend, temporal_extend_start, temporal_extend_end, + is_api, is_active, full_json, created_at, updated_at + ) VALUES ( + '1.0.0', 'Collection', 'MODIS Daily', + 'MODIS daily composites from NASA Earth Observatories', 'CC0-1.0', + ST_MakeEnvelope(-180, -90, 180, 90, 4326), + '2000-02-24T00:00:00Z'::timestamp, '2023-12-31T00:00:00Z'::timestamp, + TRUE, TRUE, + jsonb_build_object( + 'id','modis', + 'stac_version','1.0.0', + 'title','MODIS Daily' + ), + '2000-02-24T00:00:00Z'::timestamp, + '2023-12-31T00:00:00Z'::timestamp + ); + END IF; + + SELECT id INTO v_collection_id FROM collection WHERE title = 'MODIS Daily' ORDER BY id DESC LIMIT 1; + + -- Ensure temporal end is set if previously null + UPDATE collection + SET temporal_extend_end = updated_at + WHERE id = v_collection_id AND temporal_extend_end IS NULL; + + INSERT INTO collection_keywords (collection_id, keyword_id) + SELECT v_collection_id, k.id FROM keywords k + WHERE k.keyword IN ('modis','daily','thermal','visible') + AND NOT EXISTS ( + SELECT 1 FROM collection_keywords ck WHERE ck.collection_id = v_collection_id AND ck.keyword_id = k.id + ); + + SELECT id INTO v_provider_id FROM providers WHERE provider = 'NASA'; + IF v_provider_id IS NOT NULL THEN + INSERT INTO collection_providers (collection_id, provider_id, collection_provider_roles) + SELECT v_collection_id, v_provider_id, 'producer,licensor' + WHERE NOT EXISTS ( + SELECT 1 FROM collection_providers cp WHERE cp.collection_id = v_collection_id AND cp.provider_id = v_provider_id + ); + END IF; +END $$; From ac1ffe2125c442d41800489ca60b60123da65ea3 Mon Sep 17 00:00:00 2001 From: Georgios Voulgaris Date: Tue, 16 Dec 2025 18:05:14 +0100 Subject: [PATCH 2/4] Added some comments --- .../buildCollectionSearchQuery.aggregates.test.js | 1 + api/data/collections.js | 9 ++++++--- api/db/buildCollectionSearchQuery.js | 5 ++++- api/db/db_APIconnection.js | 2 ++ api/routes/collections.js | 7 +++++++ 5 files changed, 20 insertions(+), 4 deletions(-) diff --git a/api/__tests__/buildCollectionSearchQuery.aggregates.test.js b/api/__tests__/buildCollectionSearchQuery.aggregates.test.js index cb7ea9e..3238572 100644 --- a/api/__tests__/buildCollectionSearchQuery.aggregates.test.js +++ b/api/__tests__/buildCollectionSearchQuery.aggregates.test.js @@ -59,6 +59,7 @@ describe('buildCollectionSearchQuery - aggregated fields', () => { expect(sql).toMatch(/WHERE cse\.collection_id = c\.id/); }); + // Provider roles are converted to arrays in SQL via string_to_array test('includes LATERAL JOIN for providers with roles', () => { const { sql } = buildCollectionSearchQuery({ limit: 10, token: 0 }); diff --git a/api/data/collections.js b/api/data/collections.js index 49e037c..738084c 100644 --- a/api/data/collections.js +++ b/api/data/collections.js @@ -5,6 +5,9 @@ // containing common STAC fields (id, title, description, keywords, extent, etc). // In a production deployment this should be replaced by a database query // that returns fully validated STAC Collection objects. +// Minimal in-memory STAC Collection samples used for fallback/tests. +// Production uses DB-backed collections; these ensure endpoints remain functional +// and STAC-compliant when the DB is unavailable. module.exports = [ { id: 'sentinel-2-l2a', @@ -12,7 +15,7 @@ module.exports = [ type: 'Collection', title: 'Sentinel-2 L2A Collection', description: 'Sentinel-2 Level-2A processed imagery from Copernicus', - keywords: ['sentinel-2', 'optical', 'multispectral'], + keywords: ['sentinel-2', 'optical', 'multispectral'], // basic tags for fallback search license: 'CC-BY-4.0', created: '2018-01-01T00:00:00Z', updated: '2025-01-01T00:00:00Z', @@ -24,8 +27,8 @@ module.exports = [ } ], extent: { - spatial: { bbox: [[-180, -90, 180, 90]] }, - temporal: { interval: [['2015-06-23T00:00:00Z', null]] } + spatial: { bbox: [[-180, -90, 180, 90]] }, // world bbox placeholder + temporal: { interval: [['2015-06-23T00:00:00Z', null]] } // open-ended until latest }, summaries: { 'eo:bands': [ diff --git a/api/db/buildCollectionSearchQuery.js b/api/db/buildCollectionSearchQuery.js index b8eb94c..75849d5 100644 --- a/api/db/buildCollectionSearchQuery.js +++ b/api/db/buildCollectionSearchQuery.js @@ -114,7 +114,7 @@ function buildCollectionSearchQuery(params) { const values = []; let i = 1; - // Full-text search using weighted tsvector across title (weight A) and description (weight B). + // Full-text search using tsvector across title and description. // // Notes: // - Currently only title and description are included in the weighted tsvector. @@ -199,6 +199,8 @@ function buildCollectionSearchQuery(params) { // // LATERAL JOINs aggregate related data (keywords, extensions, providers, assets, summaries, // and crawl timestamps) from normalized tables without duplicating collection rows. + // NOTE: Provider roles are stored as a comma-separated string and converted to array + // via string_to_array for STAC compliance. // Each LEFT JOIN LATERAL subquery returns a single aggregated row per collection. let sql = selectPart + ` FROM collection c @@ -274,6 +276,7 @@ function buildCollectionSearchQuery(params) { if (sortby) { const sortField = sortby.field; const dir = sortby.direction; + // Apply ASCII collation only for license to match deterministic tests const collate = sortField === 'license' ? ' COLLATE "C"' : ''; sql += ` ORDER BY c.${sortField}${collate} ${dir}, c.id ASC`; } else if (q) { diff --git a/api/db/db_APIconnection.js b/api/db/db_APIconnection.js index 1cfd0bb..309fe71 100644 --- a/api/db/db_APIconnection.js +++ b/api/db/db_APIconnection.js @@ -1,6 +1,7 @@ const { Pool } = require('pg'); require('dotenv').config({ override: true }); +// Detect Jest/test environment to suppress noisy pool logs during tests const IS_TEST = process.env.NODE_ENV === 'test' || process.env.JEST_WORKER_ID !== undefined; // PostgreSQL/PostGIS database connection @@ -48,6 +49,7 @@ pool.on('error', (err) => { }); // Handle pool connection events for monitoring (only in non-test environments) +// These logs can cause Jest "Cannot log after tests" warnings, so we guard them. if (!IS_TEST) { pool.on('connect', (client) => { console.log('New client connected to pool'); diff --git a/api/routes/collections.js b/api/routes/collections.js index 565b342..ebce8af 100644 --- a/api/routes/collections.js +++ b/api/routes/collections.js @@ -32,6 +32,9 @@ async function runQuery(sql, params = []) { * Validated/normalized values are available in req.validatedParams. */ router.get('/', validateCollectionSearchParams, async (req, res, next) => { + // STAC Collections endpoint (DB-first with safe in-memory fallback). + // Normalizes STAC-required fields (extent, links), coerces IDs to strings, + // and removes null optional fields for spec compliance. // TODO: Think about the parameters `provider` and `license` - They are mentioned in the bid, but not in the STAC spec // TODO: Implement CQL2 filtering (GET endpoint) and add validator for `filter`, filter-lang` parameters try { @@ -55,6 +58,7 @@ router.get('/', validateCollectionSearchParams, async (req, res, next) => { collections = await runQuery(sql, values); // Shape DB rows to STAC structure if needed (add extent) + // DB stores spatial/temporal separately; STAC expects `extent` combining them. collections = collections.map(row => { if (row && (row.extent || (!row.spatial_extend && !row.temporal_extend_start && !row.temporal_extend_end))) { return row; @@ -74,6 +78,7 @@ router.get('/', validateCollectionSearchParams, async (req, res, next) => { }); } catch (dbError) { // Fallback to in-memory data store if database is not available + // Keep behavior consistent: basic q filter, sort, and pagination. console.warn('Database query failed, using in-memory data store:', dbError.message); collections = collectionsStore; @@ -264,6 +269,8 @@ router.get('/', validateCollectionSearchParams, async (req, res, next) => { * - 404 NotFound with proper error format if collection does not exist */ router.get('/:id', (req, res) => { + // STAC single-collection endpoint backed by in-memory store. + // Normalizes links, ensures `stac_extensions`, and converts id to string. // TODO: Create a proper validator middleware for :id parameter to avoid SQL injection, etc. const { id } = req.params; From 255fe0b5d6d4b5783c9b3fc88eafa87b3664a507 Mon Sep 17 00:00:00 2001 From: Georgios Voulgaris Date: Wed, 17 Dec 2025 12:21:46 +0100 Subject: [PATCH 3/4] Changed some files as said in the comments on Github - Deleted data folder - api.test.js tests first collection from the db - db_APIconnection changed some IS TEST --- .github/workflows/api-ci.yml | 3 +- api/.env.example | 31 ++++++ api/__tests__/api.test.js | 25 ++++- api/__tests__/collectionSearch.test.js | 2 - api/data/collections.js | 143 ------------------------- api/db/db_APIconnection.js | 14 ++- api/routes/collections.js | 1 - 7 files changed, 59 insertions(+), 160 deletions(-) create mode 100644 api/.env.example delete mode 100644 api/data/collections.js diff --git a/.github/workflows/api-ci.yml b/.github/workflows/api-ci.yml index 03407ce..f8d9a24 100644 --- a/.github/workflows/api-ci.yml +++ b/.github/workflows/api-ci.yml @@ -284,8 +284,7 @@ jobs: python -m stac_api_validator \ --root-url http://localhost:3000 \ --conformance core \ - --conformance collections \ - --collection sentinel-2-l2a + --conformance collections - name: Kill API server if: always() diff --git a/api/.env.example b/api/.env.example new file mode 100644 index 0000000..5906869 --- /dev/null +++ b/api/.env.example @@ -0,0 +1,31 @@ +# Server Configuration +PORT=3000 +NODE_ENV=development + + +# Database Configuration (Debian Server) +# Option 1: Use DATABASE_URL (PostgreSQL connection string) +# add DB_USER and DB_PASSWORD values +DATABASE_URL= postgresql://[**DB_USER**]:[**DB_PASSWORD**]@atlas.stacindex.org:5432/stac_db + +# Option 2: Use individual variables (currently active) +DB_HOST=atlas.stacindex.org +DB_PORT=5433 # 5432 for production +DB_NAME=stac_db +DB_USER= +DB_PASSWORD= +DB_SSL=false + +# Connection Pool Configuration +DB_POOL_MAX=20 +DB_POOL_MIN=2 +DB_IDLE_TIMEOUT=30000 +DB_CONNECTION_TIMEOUT=10000 + +# CORS Configuration +CORS_ORIGIN=* + +# API Configuration +API_TITLE=STAC Atlas +API_DESCRIPTION=A centralized platform for managing, indexing, and providing STAC Collection metadata +API_VERSION=1.1.0 diff --git a/api/__tests__/api.test.js b/api/__tests__/api.test.js index 7874c2a..f8e663a 100644 --- a/api/__tests__/api.test.js +++ b/api/__tests__/api.test.js @@ -74,7 +74,6 @@ describe('STAC API Core Endpoints', () => { it('should return a Collections structure', async () => { const response = await request(app).get('/collections').expect(200); - // expect(response.body).toHaveProperty('type', 'FeatureCollection'); (not sure if needed because some test fail) expect(response.body).toHaveProperty('collections'); expect(response.body).toHaveProperty('links'); expect(response.body).toHaveProperty('context'); @@ -146,9 +145,18 @@ describe('STAC API Core Endpoints', () => { }); it('should return STAC Collection object with required fields', async () => { - const response = await request(app).get('/collections/sentinel-2-l2a').expect(200); + // Dynamically get first available collection from DB + const collectionsResponse = await request(app).get('/collections').expect(200); + + if (collectionsResponse.body.collections.length === 0) { + console.warn('No collections available in DB, skipping test'); + return; + } + + const firstCollectionId = collectionsResponse.body.collections[0].id; + const response = await request(app).get(`/collections/${firstCollectionId}`).expect(200); - expect(response.body).toHaveProperty('id', 'sentinel-2-l2a'); + expect(response.body).toHaveProperty('id', firstCollectionId); expect(response.body).toHaveProperty('stac_version'); expect(response.body).toHaveProperty('title'); expect(response.body).toHaveProperty('description'); @@ -159,7 +167,16 @@ describe('STAC API Core Endpoints', () => { }); it('should include self and root links', async () => { - const response = await request(app).get('/collections/sentinel-2-l2a').expect(200); + // Dynamically get first available collection from DB + const collectionsResponse = await request(app).get('/collections').expect(200); + + if (collectionsResponse.body.collections.length === 0) { + console.warn('No collections available in DB, skipping test'); + return; + } + + const firstCollectionId = collectionsResponse.body.collections[0].id; + const response = await request(app).get(`/collections/${firstCollectionId}`).expect(200); const links = response.body.links; const linkRels = links.map(link => link.rel); diff --git a/api/__tests__/collectionSearch.test.js b/api/__tests__/collectionSearch.test.js index d4514f8..b0f66db 100644 --- a/api/__tests__/collectionSearch.test.js +++ b/api/__tests__/collectionSearch.test.js @@ -14,7 +14,6 @@ describe('Collection Search API - Query Parameters', () => { .get('/collections') .expect(200); - // expect(response.body).toHaveProperty('type', 'FeatureCollection') (not sure if needed because some test fail) expect(response.body).toHaveProperty('collections'); expect(response.body).toHaveProperty('context'); expect(response.body.context.limit).toBe(10); // default limit @@ -366,7 +365,6 @@ describe('Collection Search API - Query Parameters', () => { .expect(200); expect(response.body).toMatchObject({ - //type: 'FeatureCollection', (not sure if needed because some test fail) collections: expect.any(Array), links: expect.any(Array), context: { diff --git a/api/data/collections.js b/api/data/collections.js deleted file mode 100644 index 738084c..0000000 --- a/api/data/collections.js +++ /dev/null @@ -1,143 +0,0 @@ -// Small in-memory sample of collections for basic GET /collections implementation -// -// This file is intentionally simple and used only for local testing and -// unit-tests. Each entry represents a minimal STAC Collection-like object -// containing common STAC fields (id, title, description, keywords, extent, etc). -// In a production deployment this should be replaced by a database query -// that returns fully validated STAC Collection objects. -// Minimal in-memory STAC Collection samples used for fallback/tests. -// Production uses DB-backed collections; these ensure endpoints remain functional -// and STAC-compliant when the DB is unavailable. -module.exports = [ - { - id: 'sentinel-2-l2a', - stac_version: '1.0.0', - type: 'Collection', - title: 'Sentinel-2 L2A Collection', - description: 'Sentinel-2 Level-2A processed imagery from Copernicus', - keywords: ['sentinel-2', 'optical', 'multispectral'], // basic tags for fallback search - license: 'CC-BY-4.0', - created: '2018-01-01T00:00:00Z', - updated: '2025-01-01T00:00:00Z', - providers: [ - { - name: 'ESA', - roles: ['producer', 'licensor'], - url: 'https://www.esa.int/' - } - ], - extent: { - spatial: { bbox: [[-180, -90, 180, 90]] }, // world bbox placeholder - temporal: { interval: [['2015-06-23T00:00:00Z', null]] } // open-ended until latest - }, - summaries: { - 'eo:bands': [ - { name: 'B2', common_name: 'blue' }, - { name: 'B3', common_name: 'green' }, - { name: 'B4', common_name: 'red' }, - { name: 'B5', common_name: 'nir' } - ] - }, - links: [ - { - rel: 'self', - href: 'https://example.com/collections/sentinel-2-l2a', - type: 'application/json', - title: 'Sentinel-2 L2A Collection' - }, - { - rel: 'parent', - href: 'https://example.com/', - type: 'application/json', - title: 'Parent' - } - ] - }, - { - id: 'landsat-8-l1', - stac_version: '1.0.0', - type: 'Collection', - title: 'Landsat 8 Level-1', - description: 'Landsat 8 Collection 1 Level 1 data', - keywords: ['landsat', 'optical', 'multispectral'], - license: 'CC0-1.0', - created: '2013-02-11T00:00:00Z', - updated: '2024-06-01T00:00:00Z', - providers: [ - { - name: 'USGS', - roles: ['producer'], - url: 'https://www.usgs.gov/' - } - ], - extent: { - spatial: { bbox: [[-180, -90, 180, 90]] }, - temporal: { interval: [['2013-02-11T00:00:00Z', null]] } - }, - summaries: { - 'eo:bands': [ - { name: 'B1', common_name: 'coastal' }, - { name: 'B2', common_name: 'blue' }, - { name: 'B3', common_name: 'green' }, - { name: 'B4', common_name: 'red' } - ] - }, - links: [ - { - rel: 'self', - href: 'https://example.com/collections/landsat-8-l1', - type: 'application/json', - title: 'Landsat 8 Level-1' - }, - { - rel: 'parent', - href: 'https://example.com/', - type: 'application/json', - title: 'Parent' - } - ] - }, - { - id: 'modis', - stac_version: '1.0.0', - type: 'Collection', - title: 'MODIS Daily', - description: 'MODIS daily composites from NASA Earth Observatories', - keywords: ['modis', 'daily', 'thermal', 'visible'], - license: 'CC0-1.0', - created: '2000-02-24T00:00:00Z', - updated: '2023-12-31T00:00:00Z', - providers: [ - { - name: 'NASA', - roles: ['producer', 'licensor'], - url: 'https://www.nasa.gov/' - } - ], - extent: { - spatial: { bbox: [[-180, -90, 180, 90]] }, - temporal: { interval: [['2000-02-24T00:00:00Z', null]] } - }, - summaries: { - 'eo:bands': [ - { name: '1', common_name: 'red' }, - { name: '2', common_name: 'nir' }, - { name: '31', common_name: 'thermal' } - ] - }, - links: [ - { - rel: 'self', - href: 'https://example.com/collections/modis', - type: 'application/json', - title: 'MODIS Daily' - }, - { - rel: 'parent', - href: 'https://example.com/', - type: 'application/json', - title: 'Parent' - } - ] - } -]; diff --git a/api/db/db_APIconnection.js b/api/db/db_APIconnection.js index 309fe71..6313413 100644 --- a/api/db/db_APIconnection.js +++ b/api/db/db_APIconnection.js @@ -111,18 +111,16 @@ async function testConnection(retries = 3, delay = 2000) { waitingCount: pool.waitingCount }; - if (!IS_TEST) { - console.log('✓ Database connection successful'); - console.log(` Database: ${result.rows[0].database}`); - console.log(` PostgreSQL version: ${result.rows[0].version.split(',')[0]}`); - console.log(` Pool status: ${poolInfo.totalCount} total, ${poolInfo.idleCount} idle, ${poolInfo.waitingCount} waiting`); - } + console.log('✓ Database connection successful'); + console.log(` Database: ${result.rows[0].database}`); + console.log(` PostgreSQL version: ${result.rows[0].version.split(',')[0]}`); + console.log(` Pool status: ${poolInfo.totalCount} total, ${poolInfo.idleCount} idle, ${poolInfo.waitingCount} waiting`); return true; } catch (error) { console.error(`✗ Connection attempt ${i + 1}/${retries} failed:`, error.message); if (i < retries - 1) { - if (!IS_TEST) console.log(` Retrying in ${delay / 1000} seconds...`); + console.log(` Retrying in ${delay / 1000} seconds...`); await new Promise(resolve => setTimeout(resolve, delay)); } } @@ -253,7 +251,7 @@ async function queryByDistance(table, point, distance, geomColumn = 'spatial_ext async function closePool() { try { await pool.end(); - if (!IS_TEST) console.log('✓ Database connection pool closed'); + console.log('✓ Database connection pool closed'); } catch (error) { console.error('Error closing database pool:', error.message); throw error; diff --git a/api/routes/collections.js b/api/routes/collections.js index ebce8af..c205b61 100644 --- a/api/routes/collections.js +++ b/api/routes/collections.js @@ -32,7 +32,6 @@ async function runQuery(sql, params = []) { * Validated/normalized values are available in req.validatedParams. */ router.get('/', validateCollectionSearchParams, async (req, res, next) => { - // STAC Collections endpoint (DB-first with safe in-memory fallback). // Normalizes STAC-required fields (extent, links), coerces IDs to strings, // and removes null optional fields for spec compliance. // TODO: Think about the parameters `provider` and `license` - They are mentioned in the bid, but not in the STAC spec From b5e39631732a7b466edb15d58bd509dda74cbd3e Mon Sep 17 00:00:00 2001 From: Georgios Voulgaris Date: Wed, 17 Dec 2025 14:31:22 +0100 Subject: [PATCH 4/4] deleted A DB file which we dont needed and deleted everything with the in memory data --- api/routes/collections.js | 120 ++++++++---------------- db/init/06_seed_data.sql | 190 -------------------------------------- 2 files changed, 41 insertions(+), 269 deletions(-) delete mode 100644 db/init/06_seed_data.sql diff --git a/api/routes/collections.js b/api/routes/collections.js index c205b61..173eea6 100644 --- a/api/routes/collections.js +++ b/api/routes/collections.js @@ -1,6 +1,5 @@ const express = require('express'); const router = express.Router(); -const collectionsStore = require('../data/collections'); // change with the real collections when we have them const { validateCollectionSearchParams } = require('../middleware/validateCollectionSearch'); const { query } = require('../db/db_APIconnection'); const { buildCollectionSearchQuery } = require('../db/buildCollectionSearchQuery'); @@ -76,59 +75,14 @@ router.get('/', validateCollectionSearchParams, async (req, res, next) => { return Object.assign({}, row, { extent }); }); } catch (dbError) { - // Fallback to in-memory data store if database is not available - // Keep behavior consistent: basic q filter, sort, and pagination. - console.warn('Database query failed, using in-memory data store:', dbError.message); - collections = collectionsStore; - - // Apply basic filtering to in-memory data - if (q) { - const qLower = q.toLowerCase(); - collections = collections.filter(c => - (c.title && c.title.toLowerCase().includes(qLower)) || - (c.description && c.description.toLowerCase().includes(qLower)) || - (c.keywords && c.keywords.some(k => k.toLowerCase().includes(qLower))) - ); - } - - if (sortby) { - // sortby is normalized by validator to { field: , direction: 'ASC'|'DESC' } - const dbField = sortby.field; - const direction = sortby.direction; - - // Map DB field names to in-memory keys - const inMemoryFieldMap = { - id: 'id', - title: 'title', - license: 'license', - created_at: 'created', - updated_at: 'updated' - }; - - const fieldKey = inMemoryFieldMap[dbField] || dbField; - - collections = [...collections].sort((a, b) => { - const aVal = a[fieldKey] ?? ''; - const bVal = b[fieldKey] ?? ''; - - // Date-aware compare for created/updated - const isDateField = fieldKey === 'created' || fieldKey === 'updated'; - let comparison; - if (isDateField) { - const aTime = aVal ? new Date(aVal).getTime() : 0; - const bTime = bVal ? new Date(bVal).getTime() : 0; - comparison = aTime === bTime ? 0 : (aTime < bTime ? -1 : 1); - } else { - comparison = String(aVal).localeCompare(String(bVal)); - } - - return direction === 'DESC' ? -comparison : comparison; - }); - } - - // Apply pagination - const start = token || 0; - collections = collections.slice(start, start + limit); + // Database connection failed + console.error('Database query failed:', dbError.message); + res.status(503).json({ + code: 'ServiceUnavailable', + description: 'Database service is not available', + error: dbError.message + }); + return; } const returned = collections.length; @@ -155,9 +109,14 @@ router.get('/', validateCollectionSearchParams, async (req, res, next) => { const countResult = await runQuery(countQuery, countValues); matched = parseInt(countResult[0]?.total || 0); } catch (countError) { - // Fallback to in-memory count - console.warn('Count query failed, using in-memory count:', countError.message); - matched = collectionsStore.length; + // Count query failed - database error + console.error('Count query failed:', countError.message); + res.status(503).json({ + code: 'ServiceUnavailable', + description: 'Database service is not available', + error: countError.message + }); + return; } // Base URL for links @@ -267,24 +226,24 @@ router.get('/', validateCollectionSearchParams, async (req, res, next) => { * - 200 OK with full Collection object if found * - 404 NotFound with proper error format if collection does not exist */ -router.get('/:id', (req, res) => { - // STAC single-collection endpoint backed by in-memory store. - // Normalizes links, ensures `stac_extensions`, and converts id to string. - // TODO: Create a proper validator middleware for :id parameter to avoid SQL injection, etc. - const { id } = req.params; - - // Look up the collection in the data store by ID - // When connected to a DB, replace this with a SQL query (SELECT * FROM collections WHERE id = ?) - const collection = collectionsStore.find(c => c.id === id); - - if (!collection) { - // Return 404 with standardized error format - return res.status(404).json({ - code: 'NotFound', - description: `Collection with id '${id}' not found`, - id: id - }); - } +router.get('/:id', async (req, res, next) => { + try { + const { id } = req.params; + + // Query the database for the collection by ID + const sql = 'SELECT * FROM collections WHERE id = $1'; + const result = await query(sql, [id]); + + if (result.rows.length === 0) { + // Return 404 with standardized error format + return res.status(404).json({ + code: 'NotFound', + description: `Collection with id '${id}' not found`, + id: id + }); + } + + const collection = result.rows[0]; // Return the full STAC Collection object // Ensure the response includes at least self, root and parent links. @@ -326,14 +285,17 @@ router.get('/:id', (req, res) => { const collectionId = typeof collection.id === 'string' ? collection.id : String(collection.id); // Build response and remove null optional fields for STAC compliance - const result = Object.assign({}, collection, { id: collectionId, links: filteredLinks, stac_extensions }); + const collectionResponse = Object.assign({}, collection, { id: collectionId, links: filteredLinks, stac_extensions }); // Remove null assets, summaries (optional in STAC, should be omitted if not present) - if (result.assets === null) delete result.assets; - if (result.summaries === null) delete result.summaries; + if (collectionResponse.assets === null) delete collectionResponse.assets; + if (collectionResponse.summaries === null) delete collectionResponse.summaries; // Return the collection with a normalized `links` array and stac_extensions - res.json(result); + res.json(collectionResponse); + } catch (error) { + next(error); + } }); module.exports = router; diff --git a/db/init/06_seed_data.sql b/db/init/06_seed_data.sql deleted file mode 100644 index 209deaa..0000000 --- a/db/init/06_seed_data.sql +++ /dev/null @@ -1,190 +0,0 @@ --- I did not wrote this code myself. It was generated by ChatGPT based on my instructions. It needs to be reviewed and tested. - - --- Seed data for collections, keywords, and providers (idempotent) --- This script can be executed repeatedly without creating duplicates. - --- Root catalog (optional) -INSERT INTO catalog (stac_version, type, title, description) -SELECT '1.0.0', 'Catalog', 'STAC Atlas Root', 'Root STAC catalog for tests' -WHERE NOT EXISTS ( - SELECT 1 FROM catalog WHERE title = 'STAC Atlas Root' -); - --- Keywords -INSERT INTO keywords (keyword) VALUES - ('sentinel-2'), - ('optical'), - ('multispectral'), - ('landsat'), - ('modis'), - ('daily'), - ('thermal'), - ('visible') -ON CONFLICT (keyword) DO NOTHING; - --- Providers -INSERT INTO providers (provider) VALUES - ('ESA'), - ('USGS'), - ('NASA') -ON CONFLICT (provider) DO NOTHING; - --- Sentinel-2 L2A -DO $$ -DECLARE - v_collection_id INTEGER; - v_provider_id INTEGER; -BEGIN - IF NOT EXISTS ( - SELECT 1 FROM collection WHERE title = 'Sentinel-2 L2A Collection' - ) THEN - INSERT INTO collection ( - stac_version, type, title, description, license, - spatial_extend, temporal_extend_start, temporal_extend_end, - is_api, is_active, full_json, created_at, updated_at - ) VALUES ( - '1.0.0', 'Collection', 'Sentinel-2 L2A Collection', - 'Sentinel-2 Level-2A processed imagery from Copernicus', 'CC-BY-4.0', - ST_MakeEnvelope(-180, -90, 180, 90, 4326), - '2015-06-23T00:00:00Z'::timestamp, '2025-01-01T00:00:00Z'::timestamp, - TRUE, TRUE, - jsonb_build_object( - 'id','sentinel-2-l2a', - 'stac_version','1.0.0', - 'title','Sentinel-2 L2A Collection' - ), - '2018-01-01T00:00:00Z'::timestamp, - '2025-01-01T00:00:00Z'::timestamp - ); - END IF; - - SELECT id INTO v_collection_id FROM collection WHERE title = 'Sentinel-2 L2A Collection' ORDER BY id DESC LIMIT 1; - - -- Ensure temporal end is set if previously null - UPDATE collection - SET temporal_extend_end = updated_at - WHERE id = v_collection_id AND temporal_extend_end IS NULL; - - INSERT INTO collection_keywords (collection_id, keyword_id) - SELECT v_collection_id, k.id FROM keywords k - WHERE k.keyword IN ('sentinel-2','optical','multispectral') - AND NOT EXISTS ( - SELECT 1 FROM collection_keywords ck WHERE ck.collection_id = v_collection_id AND ck.keyword_id = k.id - ); - - SELECT id INTO v_provider_id FROM providers WHERE provider = 'ESA'; - IF v_provider_id IS NOT NULL THEN - INSERT INTO collection_providers (collection_id, provider_id, collection_provider_roles) - SELECT v_collection_id, v_provider_id, 'producer,licensor' - WHERE NOT EXISTS ( - SELECT 1 FROM collection_providers cp WHERE cp.collection_id = v_collection_id AND cp.provider_id = v_provider_id - ); - END IF; -END $$; - --- Landsat 8 Level-1 -DO $$ -DECLARE - v_collection_id INTEGER; - v_provider_id INTEGER; -BEGIN - IF NOT EXISTS ( - SELECT 1 FROM collection WHERE title = 'Landsat 8 Level-1' - ) THEN - INSERT INTO collection ( - stac_version, type, title, description, license, - spatial_extend, temporal_extend_start, temporal_extend_end, - is_api, is_active, full_json, created_at, updated_at - ) VALUES ( - '1.0.0', 'Collection', 'Landsat 8 Level-1', - 'Landsat 8 Collection 1 Level 1 data', 'CC0-1.0', - ST_MakeEnvelope(-180, -90, 180, 90, 4326), - '2013-02-11T00:00:00Z'::timestamp, '2024-06-01T00:00:00Z'::timestamp, - TRUE, TRUE, - jsonb_build_object( - 'id','landsat-8-l1', - 'stac_version','1.0.0', - 'title','Landsat 8 Level-1' - ), - '2013-02-11T00:00:00Z'::timestamp, - '2024-06-01T00:00:00Z'::timestamp - ); - END IF; - - SELECT id INTO v_collection_id FROM collection WHERE title = 'Landsat 8 Level-1' ORDER BY id DESC LIMIT 1; - - -- Ensure temporal end is set if previously null - UPDATE collection - SET temporal_extend_end = updated_at - WHERE id = v_collection_id AND temporal_extend_end IS NULL; - - INSERT INTO collection_keywords (collection_id, keyword_id) - SELECT v_collection_id, k.id FROM keywords k - WHERE k.keyword IN ('landsat','optical','multispectral') - AND NOT EXISTS ( - SELECT 1 FROM collection_keywords ck WHERE ck.collection_id = v_collection_id AND ck.keyword_id = k.id - ); - - SELECT id INTO v_provider_id FROM providers WHERE provider = 'USGS'; - IF v_provider_id IS NOT NULL THEN - INSERT INTO collection_providers (collection_id, provider_id, collection_provider_roles) - SELECT v_collection_id, v_provider_id, 'producer' - WHERE NOT EXISTS ( - SELECT 1 FROM collection_providers cp WHERE cp.collection_id = v_collection_id AND cp.provider_id = v_provider_id - ); - END IF; -END $$; - --- MODIS Daily -DO $$ -DECLARE - v_collection_id INTEGER; - v_provider_id INTEGER; -BEGIN - IF NOT EXISTS ( - SELECT 1 FROM collection WHERE title = 'MODIS Daily' - ) THEN - INSERT INTO collection ( - stac_version, type, title, description, license, - spatial_extend, temporal_extend_start, temporal_extend_end, - is_api, is_active, full_json, created_at, updated_at - ) VALUES ( - '1.0.0', 'Collection', 'MODIS Daily', - 'MODIS daily composites from NASA Earth Observatories', 'CC0-1.0', - ST_MakeEnvelope(-180, -90, 180, 90, 4326), - '2000-02-24T00:00:00Z'::timestamp, '2023-12-31T00:00:00Z'::timestamp, - TRUE, TRUE, - jsonb_build_object( - 'id','modis', - 'stac_version','1.0.0', - 'title','MODIS Daily' - ), - '2000-02-24T00:00:00Z'::timestamp, - '2023-12-31T00:00:00Z'::timestamp - ); - END IF; - - SELECT id INTO v_collection_id FROM collection WHERE title = 'MODIS Daily' ORDER BY id DESC LIMIT 1; - - -- Ensure temporal end is set if previously null - UPDATE collection - SET temporal_extend_end = updated_at - WHERE id = v_collection_id AND temporal_extend_end IS NULL; - - INSERT INTO collection_keywords (collection_id, keyword_id) - SELECT v_collection_id, k.id FROM keywords k - WHERE k.keyword IN ('modis','daily','thermal','visible') - AND NOT EXISTS ( - SELECT 1 FROM collection_keywords ck WHERE ck.collection_id = v_collection_id AND ck.keyword_id = k.id - ); - - SELECT id INTO v_provider_id FROM providers WHERE provider = 'NASA'; - IF v_provider_id IS NOT NULL THEN - INSERT INTO collection_providers (collection_id, provider_id, collection_provider_roles) - SELECT v_collection_id, v_provider_id, 'producer,licensor' - WHERE NOT EXISTS ( - SELECT 1 FROM collection_providers cp WHERE cp.collection_id = v_collection_id AND cp.provider_id = v_provider_id - ); - END IF; -END $$;