Skip to content

Commit 9fc30c5

Browse files
authored
CMR-7648: Add storage extension (#397)
* Add storage extension * Remove importing StacExtension
1 parent 8b2a533 commit 9fc30c5

8 files changed

Lines changed: 367 additions & 51 deletions

File tree

src/domains/__tests__/collections.spec.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,11 +164,13 @@ describe("collectionsToStac", () => {
164164
href: "s3://op-test-protected/SOME_DATA.002",
165165
title: "op_test_protected_SOME_DATA_002",
166166
roles: ["data"],
167+
"storage:refs": ["aws"],
167168
},
168169
s3_op_test_public_SOME_DATA_V003: {
169170
href: "s3://op-test-public/SOME_DATA.V003",
170171
title: "op_test_public_SOME_DATA_V003",
171172
roles: ["data"],
173+
"storage:refs": ["aws"],
172174
},
173175
s3_credentials: {
174176
href: "https://data.lpdaac.earthdatacloud.nasa.gov/s3credentials",
@@ -204,18 +206,75 @@ describe("collectionsToStac", () => {
204206
"https://data.lpdaac.earthdatacloud.nasa.gov/s3credentialsREADME",
205207
};
206208

209+
const stacCollection: any = collectionToStac(base);
210+
expect(stacCollection).to.have.deep.property("assets", {
211+
s3_op_test_protected_SOME_DATA_002: {
212+
href: "s3://op-test-protected/SOME_DATA.002",
213+
title: "op_test_protected_SOME_DATA_002",
214+
roles: ["data"],
215+
"storage:refs": ["aws"],
216+
},
217+
s3_op_test_public_SOME_DATA_V003: {
218+
href: "s3://op-test-public/SOME_DATA.V003",
219+
title: "op_test_public_SOME_DATA_V003",
220+
roles: ["data"],
221+
"storage:refs": ["aws"],
222+
},
223+
s3_credentials: {
224+
href: "https://data.lpdaac.earthdatacloud.nasa.gov/s3credentials",
225+
roles: ["metadata"],
226+
title: "S3 credentials API endpoint",
227+
},
228+
s3_credentials_documentation: {
229+
href: "https://data.lpdaac.earthdatacloud.nasa.gov/s3credentialsREADME",
230+
roles: ["metadata"],
231+
title: "S3 credentials API endpoint documentation",
232+
},
233+
metadata: {
234+
href: "undefined/search/concepts/C00000000-TEST_PROV.xml",
235+
roles: ["metadata"],
236+
title: "CMR XML metadata for C00000000-TEST_PROV",
237+
type: "application/xml",
238+
},
239+
});
240+
expect(stacCollection).to.have.deep.property("storage:schemes", {
241+
aws: {
242+
type: "aws-s3",
243+
platform: "https://{bucket}.s3.{region}.amazonaws.com",
244+
bucket: "op-test-protected",
245+
region: "us-west-2",
246+
},
247+
});
248+
});
249+
});
250+
describe("given multiple S3 links with public and then protected buckets", () => {
251+
it("should return a STAC collection with storage:refs preferring the protected bucket", () => {
252+
const [base] = generateCollections(1);
253+
base.directDistributionInformation = {
254+
region: "us-west-2",
255+
s3BucketAndObjectPrefixNames: [
256+
"s3://op-test-public/SOME_DATA.V003",
257+
"s3://op-test-protected/SOME_DATA.002",
258+
],
259+
s3CredentialsApiEndpoint: "https://data.lpdaac.earthdatacloud.nasa.gov/s3credentials",
260+
s3CredentialsApiDocumentationUrl:
261+
"https://data.lpdaac.earthdatacloud.nasa.gov/s3credentialsREADME",
262+
};
263+
207264
const stacCollection: any = collectionToStac(base);
208265

209266
expect(stacCollection).to.have.deep.property("assets", {
210267
s3_op_test_protected_SOME_DATA_002: {
211268
href: "s3://op-test-protected/SOME_DATA.002",
212269
title: "op_test_protected_SOME_DATA_002",
213270
roles: ["data"],
271+
"storage:refs": ["aws"],
214272
},
215273
s3_op_test_public_SOME_DATA_V003: {
216274
href: "s3://op-test-public/SOME_DATA.V003",
217275
title: "op_test_public_SOME_DATA_V003",
218276
roles: ["data"],
277+
"storage:refs": ["aws"],
219278
},
220279
s3_credentials: {
221280
href: "https://data.lpdaac.earthdatacloud.nasa.gov/s3credentials",
@@ -234,6 +293,15 @@ describe("collectionsToStac", () => {
234293
type: "application/xml",
235294
},
236295
});
296+
297+
expect(stacCollection).to.have.deep.property("storage:schemes", {
298+
aws: {
299+
type: "aws-s3",
300+
platform: "https://{bucket}.s3.{region}.amazonaws.com",
301+
bucket: "op-test-protected", // select protected even if public bucket comes first
302+
region: "us-west-2",
303+
},
304+
});
237305
});
238306
});
239307
});

src/domains/__tests__/items.spec.ts

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ const { expect } = chai;
33

44
import { granuleToStac } from "../items";
55
import { generateGranules } from "../../utils/testUtils";
6+
import { RelatedUrlType, UrlContentType } from "../../models/GraphQLModels";
67

78
const [baseGranule] = generateGranules(1);
89

@@ -268,4 +269,112 @@ describe("granuleToStac", () => {
268269
});
269270
});
270271
});
272+
describe("given it provides AWS S3 links in related urls", () => {
273+
it("should return a STACItem", () => {
274+
const [granule] = generateGranules(1);
275+
granule.relatedUrls?.push(
276+
{
277+
urlContentType: UrlContentType.DISTRIBUTION_URL,
278+
url: "s3://myBucket/path/to/object/A.01.tif",
279+
description: "S3 Download",
280+
type: RelatedUrlType.GET_DATA_VIA_DIRECT_ACCESS,
281+
},
282+
{
283+
urlContentType: UrlContentType.DISTRIBUTION_URL,
284+
url: "s3://myBucket/path/to/object/A.02.tif",
285+
description: "S3 Download",
286+
type: RelatedUrlType.GET_DATA_VIA_DIRECT_ACCESS,
287+
}
288+
);
289+
const item = granuleToStac(granule);
290+
expect(item).to.deep.equal({
291+
type: "Feature",
292+
id: granule.title,
293+
stac_version: "1.0.0",
294+
stac_extensions: ["https://stac-extensions.github.io/storage/v2.0.0/schema.json"],
295+
bbox: [-180, -90, 180, 90],
296+
geometry: null,
297+
properties: {
298+
datetime: "2009-09-14T00:00:00.000Z",
299+
start_datetime: "2009-09-14T00:00:00.000Z",
300+
end_datetime: "2010-09-14T00:00:00.000Z",
301+
"storage:schemes": {
302+
aws: {
303+
bucket: "myBucket",
304+
platform: "https://{bucket}.s3.{region}.amazonaws.com",
305+
region: "us-west-2",
306+
type: "aws-s3",
307+
},
308+
},
309+
},
310+
collection: "TEST_COLLECTION_1",
311+
links: [
312+
{
313+
href: "undefined/search/concepts/G000000000-TEST_PROV.json",
314+
rel: "via",
315+
title: "CMR JSON metadata for item",
316+
type: "application/json",
317+
},
318+
{
319+
href: "undefined/search/concepts/G000000000-TEST_PROV.umm_json",
320+
rel: "via",
321+
title: "CMR UMM_JSON metadata for item",
322+
type: "application/vnd.nasa.cmr.umm+json",
323+
},
324+
],
325+
assets: {
326+
B09: {
327+
description: "Browse image for Earthdata Search",
328+
href: "ftp://e4ftl014.cr.usgs.gov/MODIS_Composites/MOTA/.B09.tif",
329+
title: "Direct Download [0]",
330+
roles: ["data"],
331+
},
332+
asset_key: {
333+
description: "Example of bad url data",
334+
href: "ftp://e4ftl015/ExampleBadUrl",
335+
title: "Direct Download [1]",
336+
roles: ["data"],
337+
},
338+
s3_01: {
339+
href: "s3://myBucket/path/to/object/A.01.tif",
340+
title: "S3 Direct Download [0]",
341+
description: "S3 Download",
342+
roles: ["data"],
343+
"storage:refs": ["aws"],
344+
},
345+
s3_02: {
346+
href: "s3://myBucket/path/to/object/A.02.tif",
347+
title: "S3 Direct Download [1]",
348+
description: "S3 Download",
349+
roles: ["data"],
350+
"storage:refs": ["aws"],
351+
},
352+
provider_metadata: {
353+
href: "ftp://e4ftl01.cr.usgs.gov/MODIS_Composites/MOTA/MCD43A4.005/2009.09.14/MCD43A4.A2009257.h29v03.005.2009276045828.hdf.xml",
354+
description: "metadata",
355+
title: "Provider Metadata",
356+
roles: ["metadata"],
357+
},
358+
thumbnail_0: {
359+
href: "ftp://e4ftl01.cr.usgs.gov/MODIS_Composites/MOTA/MCD43A4.005/2009.09.14/MCD43A4.A2009257.h29v03.005.2009276045828.vinr.img",
360+
title: "Thumbnail [0]",
361+
description: "Browse image for Earthdata Search",
362+
roles: ["thumbnail"],
363+
},
364+
thumbnail_1: {
365+
href: "ftp://e4ftl012/ExampleBadUrl",
366+
title: "Thumbnail [1]",
367+
description: "Browse image for Earthdata Search",
368+
roles: ["thumbnail"],
369+
},
370+
metadata: {
371+
href: "undefined/search/concepts/G000000000-TEST_PROV.xml",
372+
roles: ["metadata"],
373+
title: "CMR XML metadata for G000000000-TEST_PROV",
374+
type: "application/xml",
375+
},
376+
},
377+
});
378+
});
379+
});
271380
});

src/domains/__tests__/stac.spec.ts

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import chai from "chai";
22
const { expect } = chai;
33
import { stringify as stringifyQuery } from "qs";
44

5-
import { buildQuery, sortByToSortKeys, browseAssets } from "../stac";
5+
import { buildQuery, sortByToSortKeys, browseAssets, s3downloadAssets } from "../stac";
66
import { RelatedUrlType, UrlContentType } from "../../models/GraphQLModels";
77
import { SortObject } from "../../models/StacModels";
88

@@ -570,6 +570,45 @@ describe("browseAssets", () => {
570570
});
571571
});
572572

573+
describe("s3downloadAssets", () => {
574+
it("should extract S3 URL from relatedUrls", () => {
575+
const granule = {
576+
relatedUrls: [
577+
{
578+
type: RelatedUrlType.GET_DATA_VIA_DIRECT_ACCESS,
579+
url: "s3://bucket-name/path/to/granule",
580+
description: "S3 URL for granule",
581+
},
582+
{
583+
type: RelatedUrlType.GET_DATA,
584+
url: "https://not-s3.com/file",
585+
description: "Not S3 URL",
586+
},
587+
],
588+
};
589+
const result = s3downloadAssets(granule as any);
590+
expect(result).to.have.deep.property("s3_asset_key", {
591+
href: "s3://bucket-name/path/to/granule",
592+
title: "S3 Direct Download",
593+
roles: ["data"],
594+
description: "S3 URL for granule",
595+
"storage:refs": ["aws"],
596+
});
597+
});
598+
it("should return undefined if no S3 URL is found", () => {
599+
const granule = {
600+
relatedUrls: [
601+
{
602+
type: RelatedUrlType.GET_DATA,
603+
url: "https://not-s3.com/file",
604+
},
605+
],
606+
};
607+
const result = s3downloadAssets(granule as any);
608+
expect(result).to.deep.equal({});
609+
});
610+
});
611+
573612
describe("conversions to GraphQL", () => {
574613
describe("collections", () => {
575614
describe("given cmr-stac 1.0 style collection identifiers", () => {

src/domains/collections.ts

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,13 @@ import {
1616

1717
import { cmrSpatialToExtent } from "./bounding-box";
1818

19-
import { CMR_QUERY_MAX, extractAssets, paginateQuery } from "./stac";
19+
import {
20+
CMR_QUERY_MAX,
21+
extractAssets,
22+
paginateQuery,
23+
deriveExtensions,
24+
generateStorageExtension,
25+
} from "./stac";
2026

2127
const CMR_ROOT = process.env.CMR_URL;
2228
const STAC_VERSION = process.env.STAC_VERSION ?? "1.0.0";
@@ -232,13 +238,29 @@ const generateProviders = (collection: Collection) => [
232238
];
233239

234240
/**
235-
* Convert a GraphQL collection item into a STACCollection.
241+
* Return the storage extension schema and attributes for a collection
242+
*/
243+
const storageExtension = (collection: Collection) => {
244+
const s3Buckets = (collection.directDistributionInformation?.s3BucketAndObjectPrefixNames ?? [])
245+
.flatMap((s3Buckets) => s3Buckets.split(","))
246+
.map((s3Bucket) => s3Bucket.trim())
247+
.filter((s3Bucket) => s3Bucket)
248+
.map((s3Bucket) => (s3Bucket.startsWith("s3://") ? s3Bucket : `s3://${s3Bucket}`));
249+
if (s3Buckets.length == 0) return;
250+
return generateStorageExtension(s3Buckets);
251+
};
252+
253+
/* Convert a GraphQL collection item into a STACCollection.
236254
*/
237255
export const collectionToStac = (collection: Collection): STACCollection => {
238256
const { entryId, description, title } = collection;
239257

240258
const { license, licenseLink } = extractLicense(collection);
241259

260+
const { extensions, attributes: extensionAttributes } = deriveExtensions(collection, [
261+
storageExtension,
262+
]);
263+
242264
const assets = extractAssets(collection);
243265
const extent = createExtent(collection);
244266
const keywords = createKeywords(collection);
@@ -252,13 +274,15 @@ export const collectionToStac = (collection: Collection): STACCollection => {
252274
title,
253275
description,
254276
stac_version: STAC_VERSION,
277+
stac_extensions: extensions,
255278
extent,
256279
assets,
257280
providers,
258281
links,
259282
license,
260283
keywords,
261284
summaries,
285+
...extensionAttributes,
262286
} as STACCollection;
263287
};
264288

0 commit comments

Comments
 (0)