Skip to content
Open
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.

### Added

- Automatic temporal extent calculation for collections. When serving collections via the `/collections`
and `/collections/{collectionId}` endpoints, if a collection does not have a temporal extent defined,
the server will automatically calculate it from the earliest and latest items in the collection. To use
this feature, simply omit the `extent.temporal.interval` field when ingesting a collection.
- Asset proxying for generating pre-signed S3 URLs through proxy endpoints `GET
/collections/{collectionId}/items/{itemId}/assets/{assetKey}` and `GET
/collections/{collectionId}/assets/{assetKey}`.
Expand Down
6 changes: 6 additions & 0 deletions docs/usage/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,12 @@ Publish a STAC Item or Collection directly:
}
```

#### Automatic Temporal Extent

When ingesting Collections, the `extent.temporal.interval` field can be omitted to enable automatic temporal extent calculation. When a collection is requested via the API, if it doesn't have a temporal extent defined, stac-server will automatically calculate it by finding the earliest and latest `datetime` values from the items in that collection.

Collections with no items will have a temporal extent of `[[null, null]]`. This feature allows temporal extents to stay current as items are added or removed without requiring manual collection updates. The temporal extent is calculated dynamically each time the collection is requested, so it automatically reflects the current state of items without requiring collection updates or persisting changes to the collection document.

### Large Items

For items exceeding the 256 KB SQS message limit, publish a reference:
Expand Down
36 changes: 36 additions & 0 deletions src/lib/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -1351,6 +1351,35 @@ const deleteUnusedFields = (collection) => {
delete collection.aggregations
}

/**
* Populate temporal extent for a collection from its items if not already defined
* @param {Object} backend - Database backend
* @param {Object} collection - Collection object
* @returns {Promise<void>}
*/
const populateTemporalExtentIfMissing = async (backend, collection) => {
const id = collection.id

// Check if collection already has a temporal extent defined
const start = collection.extent?.temporal?.interval?.[0]?.[0]
const end = collection.extent?.temporal?.interval?.[0]?.[1]
const hasTemporalExtent = start != null || end != null

if (!hasTemporalExtent) {
const temporalExtent = await backend.getTemporalExtentFromItems(id)
if (temporalExtent) {
// Initialize extent structure if it doesn't exist
if (!collection.extent) {
collection.extent = {}
}
if (!collection.extent.temporal) {
collection.extent.temporal = {}
}
collection.extent.temporal.interval = temporalExtent
}
}
}

const getCollections = async function (backend, endpoint, parameters, headers) {
// TODO: implement proper pagination, as this will only return up to
// COLLECTION_LIMIT collections
Expand All @@ -1364,6 +1393,10 @@ const getCollections = async function (backend, endpoint, parameters, headers) {
(c) => isCollectionIdAllowed(allowedCollectionIds, c.id)
)

// Populate temporal extent for each collection from items only if not already defined
await Promise.all(collections.map((collection) =>
populateTemporalExtentIfMissing(backend, collection)))

for (const collection of collections) {
deleteUnusedFields(collection)
}
Expand Down Expand Up @@ -1412,6 +1445,9 @@ const getCollection = async function (backend, collectionId, endpoint, parameter
return new NotFoundError()
}

// Populate temporal extent from items only if not already defined
await populateTemporalExtentIfMissing(backend, result)

deleteUnusedFields(result)
addCollectionLinks([result], endpoint)

Expand Down
64 changes: 64 additions & 0 deletions src/lib/database.js
Original file line number Diff line number Diff line change
Expand Up @@ -1104,6 +1104,69 @@ async function healthCheck() {
return client.cat.health()
}

/**
* Calculate temporal extent for a collection by finding the earliest and latest items
* @param {string} collectionId - The collection ID
* @returns {Promise<Array|null>} Returns [[startDate, endDate]] or null if no items/datetime
*/
async function getTemporalExtentFromItems(collectionId) {
try {
const client = await _client()
if (client === undefined) throw new Error('Client is undefined')

// Get earliest item by sorting ascending
const minParams = await constructSearchParams(
{ collections: [collectionId] },
undefined,
1 // Only need the first item
)
minParams.body.sort = [{ 'properties.datetime': { order: 'asc' } }]
minParams.body._source = ['properties.datetime']

// Get latest item by sorting descending
const maxParams = await constructSearchParams(
{ collections: [collectionId] },
undefined,
1 // Only need the first item
)
maxParams.body.sort = [{ 'properties.datetime': { order: 'desc' } }]
maxParams.body._source = ['properties.datetime']

// Execute both queries in parallel
const [minResponse, maxResponse] = await Promise.all([
client.search({
ignore_unavailable: true,
allow_no_indices: true,
...minParams
}),
client.search({
ignore_unavailable: true,
allow_no_indices: true,
...maxParams
})
])

const minItem = minResponse.body.hits.hits[0]?._source
const maxItem = maxResponse.body.hits.hits[0]?._source

// If no items or no datetime values, return [[null, null]]
if (!minItem?.properties?.datetime || !maxItem?.properties?.datetime) {
return [[null, null]]
}

const startDate = minItem.properties.datetime
const endDate = maxItem.properties.datetime

return [[startDate, endDate]]
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
logger.error(
`Error calculating temporal extent for collection ${collectionId}: ${errorMessage}`
)
return null
}
}

export default {
getCollections,
getCollection,
Expand All @@ -1120,5 +1183,6 @@ export default {
constructSearchParams,
buildDatetimeQuery,
healthCheck,
getTemporalExtentFromItems,
buildFieldsFilter
}
150 changes: 150 additions & 0 deletions tests/system/test-api-temporal-extent.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
// @ts-nocheck

import test from 'ava'
import { deleteAllIndices, refreshIndices } from '../helpers/database.js'
import { ingestItem } from '../helpers/ingest.js'
import { randomId, loadFixture } from '../helpers/utils.js'
import { setup } from '../helpers/system-tests.js'

test.before(async (t) => {
await deleteAllIndices()
const standUpResult = await setup()

t.context = standUpResult
t.context.collectionId = randomId('collection')

const collection = await loadFixture(
'landsat-8-l1-collection.json',
{ id: t.context.collectionId }
)

await ingestItem({
ingestQueueUrl: t.context.ingestQueueUrl,
ingestTopicArn: t.context.ingestTopicArn,
item: collection
})

// Ingest items with different dates
const item1 = await loadFixture('stac/LC80100102015002LGN00.json', {
collection: t.context.collectionId,
properties: {
datetime: '2015-01-02T15:49:05.000Z'
}
})

const item2 = await loadFixture('stac/LC80100102015002LGN00.json', {
collection: t.context.collectionId,
id: 'item-2',
properties: {
datetime: '2020-06-15T10:30:00.000Z'
}
})

const item3 = await loadFixture('stac/LC80100102015002LGN00.json', {
collection: t.context.collectionId,
id: 'item-3',
properties: {
datetime: '2018-03-20T08:15:00.000Z'
}
})

await ingestItem({
ingestQueueUrl: t.context.ingestQueueUrl,
ingestTopicArn: t.context.ingestTopicArn,
item: item1
})

await ingestItem({
ingestQueueUrl: t.context.ingestQueueUrl,
ingestTopicArn: t.context.ingestTopicArn,
item: item2
})

await ingestItem({
ingestQueueUrl: t.context.ingestQueueUrl,
ingestTopicArn: t.context.ingestTopicArn,
item: item3
})

await refreshIndices()
})

test.after.always(async (t) => {
if (t.context.api) await t.context.api.close()
})

test('GET /collections/:collectionId returns temporal extent from items', async (t) => {
const { collectionId } = t.context

const response = await t.context.api.client.get(`collections/${collectionId}`,
{ resolveBodyOnly: false })

t.is(response.statusCode, 200)
t.is(response.body.id, collectionId)

// Check that extent.temporal.interval exists and is populated
t.truthy(response.body.extent)
t.truthy(response.body.extent.temporal)
t.truthy(response.body.extent.temporal.interval)
t.is(response.body.extent.temporal.interval.length, 1)

const [startDate, endDate] = response.body.extent.temporal.interval[0]

// Verify the start date is the earliest item datetime (2015-01-02)
t.is(startDate, '2015-01-02T15:49:05.000Z')

// Verify the end date is the latest item datetime (2020-06-15)
t.is(endDate, '2020-06-15T10:30:00.000Z')
})

test('GET /collections returns temporal extent for all collections', async (t) => {
const response = await t.context.api.client.get('collections',
{ resolveBodyOnly: false })

t.is(response.statusCode, 200)
t.truthy(response.body.collections)
t.true(response.body.collections.length > 0)

// Find our test collection
const collection = response.body.collections.find((c) => c.id === t.context.collectionId)
t.truthy(collection)

// Check that extent.temporal.interval exists and is populated
t.truthy(collection.extent)
t.truthy(collection.extent.temporal)
t.truthy(collection.extent.temporal.interval)
t.is(collection.extent.temporal.interval.length, 1)

const [startDate, endDate] = collection.extent.temporal.interval[0]

// Verify the dates match the items
t.is(startDate, '2015-01-02T15:49:05.000Z')
t.is(endDate, '2020-06-15T10:30:00.000Z')
})

test('Collection with no items has null temporal extent', async (t) => {
// Create a new collection with no items
const emptyCollectionId = randomId('empty-collection')
const emptyCollection = await loadFixture(
'landsat-8-l1-collection.json',
{ id: emptyCollectionId }
)

await ingestItem({
ingestQueueUrl: t.context.ingestQueueUrl,
ingestTopicArn: t.context.ingestTopicArn,
item: emptyCollection
})

await refreshIndices()

const response = await t.context.api.client.get(`collections/${emptyCollectionId}`,
{ resolveBodyOnly: false })

t.is(response.statusCode, 200)
t.is(response.body.id, emptyCollectionId)

// For a collection with no items, temporal extent should still exist from the original collection
// but our code should gracefully handle this (return null or keep original)
t.truthy(response.body.extent)
})
Loading