Skip to content

Commit

Permalink
CMR-10165: Extend sorting capabilities for CMR-STAC collection search (
Browse files Browse the repository at this point in the history
…#355)

* CMR-10165: Adds support sorting collection result

* CMR-10165: Added test for sortby

* CMR-10165: Updates documentation for collection search

* CMR-10165: Adds sort extension to conformance

* CMR-10165: Addresses PR feedback

* CMR-10165: Addressing PR feedback and updates the free text documentation

* CMR-10165: Fixes lint error
  • Loading branch information
dmistry1 authored Oct 4, 2024
1 parent 7482b0e commit 0590a70
Show file tree
Hide file tree
Showing 9 changed files with 240 additions and 14 deletions.
38 changes: 38 additions & 0 deletions docs/usage/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,44 @@ JSON Body:
}
```

## Searching Collections
Similar to searching for Items, CMR-STAC provides endpoints to search for Collections. Both GET and POST requests are supported for collection searches.

| Parameter | Type | Description |
| --------- | ---- | ----------- |
| bbox | [number] | Requested bounding box. Represented using 2D geometries. The length of the array must be 4: [west, south, east, north]. |
| datetime | string | Single date+time, or a range ('/' separator), formatted to RFC 3339, section 5.6. Use double dots .. for open date ranges. |
| q | string | Free text search through collection metadata. |
| sortby | string or [object] | Sort the results by one or more fields. For GET requests, use a comma-separated list of field names, optionally preceded by a '-' for descending order. For POST requests, use an array of objects with 'field' and 'direction' properties. Fields supported for sort are `startDate`, `endDate`, `id` and `title`|

**Examples**

GET:


https://localhost:3000/stac/LARC_ASDC/collections?bbox=-180,-90,180,90&datetime=2000-01-01T00:00:00Z/2022-01-01T00:00:00Z&keyword=atmosphere

**To sort the results:**
https://localhost:3000/stac/LARC_ASDC/collections?keyword=climate&sortby=-id,title

This will sort the results first by id in descending order, then by title in ascending order.

POST:
`https://localhost:3000/stac/LARC_ASDC/collections`

JSON Body:
```json
{
"bbox": [-180, -90, 180, 90],
"datetime": "2000-01-01T00:00:00Z/2022-01-01T00:00:00Z",
"keyword": "atmosphere",
"sortby": [
{"field": "id", "direction": "desc"},
{"field": "title", "direction": "asc"}
]
}
```

## Searching via CLI

The Python library [pystac-client] provides a Command Line Interface (CLI) to search any STAC API.
Expand Down
41 changes: 41 additions & 0 deletions src/__tests__/providerCollection.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,47 @@ describe("GET /:provider/collections", () => {
});
});
});

describe("sortby parameter", () => {
describe("given a valid sortby field", () => {
it("should return sorted result", async () => {
sandbox
.stub(Providers, "getProviders")
.resolves([null, [{ "provider-id": "TEST", "short-name": "TEST" }]]);

const mockCollections = generateSTACCollections(2);
sandbox.stub(Collections, "getCollections").resolves({
count: 2,
cursor: null,
items: mockCollections,
});

const { statusCode } = await request(app)
.get("/stac/TEST/collections")
.query({ sortby: "-endDate" });

expect(statusCode).to.equal(200);
});
});

describe("given a invalid sortby field", () => {
it("should return an Invalid sort field(s) error", async () => {
sandbox
.stub(Providers, "getProviders")
.resolves([null, [{ "provider-id": "TEST", "short-name": "TEST" }]]);

const { statusCode, body } = await request(app)
.get("/stac/TEST/collections")
.query({ sortby: "invalid_field" });

expect(statusCode).to.equal(400);
expect(body).to.have.property("errors");
expect(body.errors[0]).to.include(
"Invalid sort field(s). Valid fields are: startDate, endDate, id, title, eo:cloud_cover"
);
});
});
});
});

describe("POST /:provider/collections", () => {
Expand Down
18 changes: 17 additions & 1 deletion src/domains/__tests__/stac.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ const { expect } = chai;

import { buildQuery, sortByToSortKeys, stringifyQuery, browseAssets } from "../stac";
import { RelatedUrlType, UrlContentType } from "../../models/GraphQLModels";
import { SortObject } from "../../models/StacModels";

describe("buildQuery", () => {
describe("given a intersects query", () => {
Expand Down Expand Up @@ -412,6 +413,12 @@ describe("sortByToSortKeys", () => {
[
{ input: "properties.eo:cloud_cover", output: ["cloudCover"] },
{ input: "-properties.eo:cloud_cover", output: ["-cloudCover"] },
{ input: "id", output: ["shortName"] },
{ input: "-id", output: ["-shortName"] },
{ input: "title", output: ["entryTitle"] },
{ input: "-title", output: ["-entryTitle"] },
{ input: "someOtherField", output: ["someOtherField"] },
{ input: "-someOtherField", output: ["-someOtherField"] },
{
input: ["properties.eo:cloud_cover", "conceptId"],
output: ["cloudCover", "conceptId"],
Expand All @@ -421,10 +428,19 @@ describe("sortByToSortKeys", () => {
output: ["-cloudCover", "conceptId"],
},
].forEach(({ input, output }) => {
describe(`given sortBy=${input}`, () => {
describe(`given sortby=${input}`, () => {
it("should return the corresponding sortKey", () => {
expect(sortByToSortKeys(input)).to.deep.equal(output);
});

it("should handle object-based sort specifications", () => {
const input: SortObject[] = [
{ field: "properties.eo:cloud_cover", direction: "desc" },
{ field: "id", direction: "asc" },
{ field: "title", direction: "desc" },
];
expect(sortByToSortKeys(input)).to.deep.equal(["-cloudCover", "shortName", "-entryTitle"]);
});
});
});
});
Expand Down
1 change: 1 addition & 0 deletions src/domains/providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export const conformance = [
"http://www.opengis.net/spec/ogcapi-features-1/1.0/conf/geojson",
"https://api.stacspec.org/v1.0.0-rc.2/collection-search",
"https://api.stacspec.org/v1.0.0-rc.2/collection-search#free-text",
"https://api.stacspec.org/v1.0.0-rc.2/collection-search#sort",
"http://www.opengis.net/spec/ogcapi-common-1/1.0/conf/simple-query",
];

Expand Down
33 changes: 23 additions & 10 deletions src/domains/stac.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import {
GraphQLHandler,
GraphQLResults,
} from "../models/GraphQLModels";
import { StacQuery } from "../models/StacModels";
import { SortObject, StacQuery } from "../models/StacModels";
import { getAllCollectionIds } from "./collections";
import {
flattenTree,
Expand All @@ -34,6 +34,7 @@ import { dateTimeToRange } from "../utils/datetime";

import { AssetLinks } from "../@types/StacCollection";
import { Collection, Granule, RelatedUrlType } from "../models/GraphQLModels";
import { parseSortFields } from "../utils/sort";

const CMR_ROOT = process.env.CMR_URL;

Expand Down Expand Up @@ -340,23 +341,36 @@ const bboxQuery = (_req: Request, query: StacQuery) => ({
/**
* Returns a list of sortKeys from the sortBy property
*/
export const sortByToSortKeys = (sortBys?: string | string[]): string[] => {
if (!sortBys) return [];

const baseSortKeys = Array.isArray(sortBys) ? [...sortBys] : [sortBys];
export const sortByToSortKeys = (sortBys?: string | SortObject[] | string[]): string[] => {
const baseSortKeys: string[] = parseSortFields(sortBys);

return baseSortKeys.reduce((sortKeys, sortBy) => {
if (!sortBy || sortBy.trim() === "") return sortKeys;
if (sortBy.match(/(properties\.)?eo:cloud_cover$/gi)) {
return [...sortKeys, sortBy.startsWith("-") ? "-cloudCover" : "cloudCover"];

const isDescending = sortBy.startsWith("-");
const cleanSortBy = isDescending ? sortBy.slice(1) : sortBy;
// Allow for `properties` prefix
const fieldName = cleanSortBy.replace(/^properties\./, "");

let mappedField;

if (fieldName.match(/^eo:cloud_cover$/i)) {
mappedField = "cloudCover";
} else if (fieldName.match(/^id$/i)) {
mappedField = "shortName";
} else if (fieldName.match(/^title$/i)) {
mappedField = "entryTitle";
} else {
mappedField = fieldName;
}

return [...sortKeys, sortBy];
return [...sortKeys, isDescending ? `-${mappedField}` : mappedField];
}, [] as string[]);
};

const sortKeyQuery = (_req: Request, query: StacQuery) => ({
sortKey: sortByToSortKeys(query.sortBy),
// Use the sortByToSortKeys function to convert STAC sortby to CMR sortKey
sortKey: sortByToSortKeys(query.sortby),
});

const idsQuery = (req: Request, query: StacQuery) => {
Expand Down Expand Up @@ -562,7 +576,6 @@ export const paginateQuery = async (
try {
console.info(timingMessage);
const response = await request(GRAPHQL_URL, gqlQuery, variables, requestHeaders);

// use the passed in results handler
const [errors, data] = handler(response);

Expand Down
26 changes: 24 additions & 2 deletions src/middleware/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Request, Response, NextFunction } from "express";

import { StacQuery } from "../models/StacModels";
import { SortObject, StacQuery } from "../models/StacModels";
import {
ErrorHandler,
InvalidParameterError,
Expand All @@ -15,6 +15,7 @@ import { getProviders, getCloudProviders } from "../domains/providers";

import { scrubTokens, mergeMaybe, ERRORS } from "../utils";
import { validDateTime } from "../utils/datetime";
import { parseSortFields } from "../utils/sort";

const STAC_QUERY_MAX = 5000;

Expand Down Expand Up @@ -226,8 +227,23 @@ const validFreeText = (freeText: string) => {
return false;
};

const VALID_SORT_FIELDS = ["startDate", "endDate", "id", "title", "eo:cloud_cover"];

const validSortBy = (sortBy: string | string[] | SortObject[]) => {
const fields: string[] = parseSortFields(sortBy);

return fields.every((value) => {
const isDescending = value.startsWith("-");
const cleanSortBy = isDescending ? value.slice(1) : value;
// Allow for `properties` prefix
const fieldName = cleanSortBy.replace(/^properties\./, "");

return VALID_SORT_FIELDS.includes(fieldName);
});
};

const validateQueryTerms = (query: StacQuery) => {
const { bbox, intersects, datetime, limit: strLimit, q: freeText } = query;
const { bbox, datetime, intersects, limit: strLimit, q: freeText, sortby } = query;

const limit = Number.isNaN(Number(strLimit)) ? null : Number(strLimit);

Expand Down Expand Up @@ -260,6 +276,12 @@ const validateQueryTerms = (query: StacQuery) => {
"Search query must be either a single keyword or a single phrase enclosed in double quotes."
);
}

if (sortby && !validSortBy(sortby)) {
return new InvalidParameterError(
`Invalid sort field(s). Valid fields are: ${VALID_SORT_FIELDS.join(", ")}`
);
}
};

/**
Expand Down
7 changes: 6 additions & 1 deletion src/models/StacModels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,14 @@ export type PropertyQuery = {
// TODO: Add full support for STAC property query extension, see CMR-9010
};

export type SortObject = {
field: string;
direction: "asc" | "desc";
};

export type StacQuery = {
cursor?: string;
sortBy?: string | string[];
sortby?: string | SortObject[];
limit?: string;
bbox?: string;
datetime?: string;
Expand Down
48 changes: 48 additions & 0 deletions src/utils/__tests__/sort.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { expect } from "chai";
import { parseSortFields } from "../sort";
import { SortObject } from "../../models/StacModels";

describe("parseSortFields", () => {
it("should return an empty array for undefined input", () => {
const parsedField = parseSortFields();

expect(parsedField).to.deep.equal([]);
});

it("should handle a single field in string based sorting (GET)", () => {
const parsedField = parseSortFields("field1");
expect(parsedField).to.deep.equal(["field1"]);
});

it("should handle multi field string based sorting (GET)", () => {
const parsedField = parseSortFields("field1, -field2, field3");

expect(parsedField).to.deep.equal(["field1", "-field2", "field3"]);
});

it("should handle a single object in object based sorting (POST)", () => {
const input: SortObject[] = [{ field: "field1", direction: "desc" }];
expect(parseSortFields(input)).to.deep.equal(["-field1"]);
});

it("should handle multi field object based sorting (POST)", () => {
const input: SortObject[] = [
{ field: "field1", direction: "asc" },
{ field: "field2", direction: "desc" },
{ field: "field3", direction: "asc" },
];
expect(parseSortFields(input)).to.deep.equal(["field1", "-field2", "field3"]);
});

it("should return an empty array for an empty array", () => {
const parsedField = parseSortFields([]);

expect(parsedField).to.deep.equal([]);
});

it("should handle mixed array (treating non-strings as empty strings)", () => {
const input: any[] = ["field1", { field: "field2", direction: "desc" }, "-field3"];

expect(parseSortFields(input)).to.deep.equal(["field1", "", "-field3"]);
});
});
42 changes: 42 additions & 0 deletions src/utils/sort.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { SortObject } from "../models/StacModels";

/**
* Parses sortby value into a single array
* This function handles three possible input formats:
* 1. A string of comma-separated sort fields (used in GET requests)
* - /collections?sortby=endDate
* 2. An array of SortObject (used in POST requests)
* {
"sortby": [
{
"field": "properties.endDate",
"direction": "desc"
}
]
}
* 3. Undefined or null (returns an empty array)
*
* @param sortBys - The sortby value
* @returns An array of strings, each representing a sort field.
* Fields for descending sort are prefixed with '-'.
*/
export const parseSortFields = (sortBys?: string | string[] | SortObject[]): string[] => {
if (Array.isArray(sortBys)) {
if (sortBys.length === 0) return [];

if (typeof sortBys[0] === "object") {
// Handle object-based sorting (POST)
return (sortBys as SortObject[]).map(
(sort) => `${sort.direction === "desc" ? "-" : ""}${sort.field}`
);
} else {
// Handle array of strings
return sortBys.map((item) => (typeof item === "string" ? item.trim() : ""));
}
} else if (typeof sortBys === "string") {
// Handle string-based sorting (GET)
return sortBys.split(",").map((key) => key.trim());
}

return [];
};

0 comments on commit 0590a70

Please sign in to comment.