Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 103 additions & 2 deletions .github/workflows/api-ci.yml
Comment thread
georgevoulg marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -199,12 +199,113 @@ 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

- 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:
Expand Down
62 changes: 59 additions & 3 deletions api/__tests__/api.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -71,10 +71,9 @@ 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('collections');
expect(response.body).toHaveProperty('links');
expect(response.body).toHaveProperty('context');
Expand All @@ -88,6 +87,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', () => {
Expand Down Expand Up @@ -128,5 +143,46 @@ 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 () => {
// 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', firstCollectionId);
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 () => {
// 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);

expect(linkRels).toContain('self');
expect(linkRels).toContain('root');
});
});
});
3 changes: 2 additions & 1 deletion api/__tests__/buildCollectionSearchQuery.aggregates.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -59,12 +59,13 @@ 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 });

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, ','\)/);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a benefit, if we turn the providers into an array?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

STAC compliance: providers[*].roles is defined by the STAC specification as an array of strings. A comma-separated string would be incorrectly typed.
Validator: The STAC API Validator expects an array; otherwise the schema validation fails.
Clients: Frontends/clients can directly use roles.includes('producer'), etc., instead of having to implement string splitting.
Data quality: This prevents typos and errors during later splitting and keeps the typing consistent.

https://github.com/radiantearth/stac-spec/blob/master/collection-spec/collection-spec.md#provider-object

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/);
Expand Down
4 changes: 1 addition & 3 deletions api/__tests__/collectionSearch.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,7 @@ 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('collections');
expect(response.body).toHaveProperty('context');
expect(response.body.context.limit).toBe(10); // default limit
Expand Down Expand Up @@ -366,7 +365,6 @@ describe('Collection Search API - Query Parameters', () => {
.expect(200);

expect(response.body).toMatchObject({
type: 'FeatureCollection',
collections: expect.any(Array),
links: expect.any(Array),
context: {
Expand Down
105 changes: 0 additions & 105 deletions api/data/collections.js

This file was deleted.

12 changes: 9 additions & 3 deletions api/db/buildCollectionSearchQuery.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -217,7 +219,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
Expand Down Expand Up @@ -272,7 +274,11 @@ 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;
// 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`;
Comment on lines +277 to +281

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Explain me, why you are doing this

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The code sorts collections by a specific field (e.g. license or title). When sorting by license, an ASCII-based sort order is intentionally used. The reason is that different machines could otherwise produce different sort orders, depending on locale and database settings. By using COLLATE "C", the sorting is identical on all systems, ensuring that tests remain reproducible everywhere. If you think we don’t need it I can remove it.

} else if (q) {
sql += ` ORDER BY rank DESC, c.id ASC`;
} else {
Expand Down
8 changes: 6 additions & 2 deletions api/db/db_APIconnection.js

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There was already a is_test-Logik in line 49. Is it realy necessary to give the overhead also to every console.log, separately which is only send once?

I mean it's a design-choice. Im okay with this, but then we should apply your logic to literally every console.log like also lines 65, 71...

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I changed most of it, but not everything, because the pool event gets called very often.

Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
const { Pool } = require('pg');
require('dotenv').config();
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
// Support both DATABASE_URL and individual environment variables
Expand Down Expand Up @@ -46,7 +49,8 @@ pool.on('error', (err) => {
});

// Handle pool connection events for monitoring (only in non-test environments)
if (process.env.NODE_ENV !== 'test') {
// 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');
});
Expand Down
Loading
Loading