Skip to content

Commit fc946fc

Browse files
authored
@W-18706176: Changes to download latest release version of the API (#231)
* Changes to download latest release version of the API
1 parent a82ff76 commit fc946fc

8 files changed

Lines changed: 3575 additions & 13 deletions

File tree

.github/workflows/test.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@ jobs:
2626
with:
2727
path: "**/node_modules"
2828
key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/package-lock.json') }}
29+
- name: Install oasdiff
30+
run: |
31+
curl -fsSL https://raw.githubusercontent.com/oasdiff/oasdiff/main/install.sh | sh
32+
oasdiff --version
2933
- run: npm ci
3034
if: ${{ steps.cache-nodemodules.outputs.cache-hit != 'true' }}
3135
- run: npm run compile

src/download/downloadCommand.test.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,17 @@ import tmp from "tmp";
1010
import chai from "chai";
1111
import chaiFs from "chai-fs";
1212
import { DownloadCommand } from "./downloadCommand";
13+
import _ from "lodash";
1314

1415
chai.use(chaiFs);
1516

1617
// eslint-disable-next-line @typescript-eslint/no-var-requires
1718
const assetSearchResults = require("../../testResources/download/resources/assetSearch.json");
1819

1920
// eslint-disable-next-line @typescript-eslint/no-var-requires
20-
const asset = require("../../testResources/download/resources/getAsset");
21+
const assetResource = require("../../testResources/download/resources/getAsset");
22+
// Create a local copy to avoid mutating shared test resources that other tests depend on
23+
const asset = _.cloneDeep(assetResource);
2124
// Use a shorter URL for better readability
2225
asset.files.find((file) => file.classifier === "fat-raml").externalLink =
2326
"https://short.url/raml.zip";
@@ -66,7 +69,11 @@ function setup({
6669
// Intercept searchExchange request
6770
.nock("https://anypoint.mulesoft.com/exchange/api/v2", (scope) =>
6871
scope
69-
.get(`/assets?search=${encodeURIComponent(search)}&types=rest-api`)
72+
.get(
73+
`/assets?search=${encodeURIComponent(
74+
search
75+
)}&types=rest-api&limit=50&offset=0`
76+
)
7077
.reply(200, [assetSearchResults[0]])
7178
)
7279
// Intercept search requests

src/download/exchangeDirectoryParser.test.ts

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
/*
2-
* Copyright (c) 2020, salesforce.com, inc.
2+
* Copyright (c) 2025, salesforce.com, inc.
33
* All rights reserved.
44
* SPDX-License-Identifier: BSD-3-Clause
55
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
66
*/
77

8-
import { extractFiles } from "./exchangeDirectoryParser";
8+
import { extractFiles, extractFile } from "./exchangeDirectoryParser";
99

1010
import { expect, default as chai } from "chai";
1111
import chaiAsPromised from "chai-as-promised";
@@ -33,6 +33,33 @@ before(() => {
3333
chai.use(chaiAsPromised);
3434
});
3535

36+
describe("extractFile", () => {
37+
it("should reject with an error message when trying to extract an invalid zip file", async () => {
38+
const directory = tmp.dirSync();
39+
const invalidZipPath = path.join(directory.name, "invalid.zip");
40+
41+
// Create a file that looks like a zip but isn't valid
42+
fs.writeFileSync(invalidZipPath, "This is not a valid zip file");
43+
44+
await expect(extractFile(invalidZipPath)).to.be.rejectedWith(
45+
`Failed to extract ${invalidZipPath}, probably not a zip file`
46+
);
47+
});
48+
49+
it("should successfully extract a valid zip file", async () => {
50+
const directory = tmp.dirSync();
51+
const zipPath = path.join(directory.name, "api1.zip");
52+
53+
// Create a valid zip file
54+
await createZipFile(directory, "api1.zip");
55+
56+
const extractedPath = await extractFile(zipPath);
57+
58+
expect(fs.existsSync(extractedPath)).to.be.true;
59+
expect(fs.existsSync(path.join(extractedPath, "exchange.json"))).to.be.true;
60+
});
61+
});
62+
3663
describe("extractFiles", () => {
3764
it("should extract a zip file into the specified directory", async () => {
3865
const directory = tmp.dirSync();

src/download/exchangeDownloader.test.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright (c) 2020, salesforce.com, inc.
2+
* Copyright (c) 2025, salesforce.com, inc.
33
* All rights reserved.
44
* SPDX-License-Identifier: BSD-3-Clause
55
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
@@ -176,7 +176,7 @@ describe("exchangeDownloader", () => {
176176
describe("searchExchange", () => {
177177
it("can download multiple files", async () => {
178178
nock("https://anypoint.mulesoft.com/exchange/api/v2")
179-
.get("/assets?search=searchString&types=rest-api")
179+
.get("/assets?search=searchString&types=rest-api&limit=50&offset=0")
180180
.reply(200, assetSearchResults);
181181

182182
return searchExchange("AUTH_TOKEN", "searchString").then((res) => {
@@ -335,10 +335,15 @@ describe("exchangeDownloader", () => {
335335
Authorization: "Bearer AUTH_TOKEN",
336336
},
337337
})
338-
.get("/assets?search=searchString&types=rest-api")
338+
.get("/assets?search=searchString&types=rest-api&limit=50&offset=0")
339339
.reply(200, [assetSearchResults[0]]);
340340
});
341341

342+
afterEach(() => {
343+
// Clean up any remaining nock interceptors for this describe block
344+
nock.cleanAll();
345+
});
346+
342347
it("searches Exchange and filters by deployment", () => {
343348
scope
344349
.get("/shop-products-categories-api-v1")
@@ -390,7 +395,7 @@ describe("exchangeDownloader", () => {
390395
asset.fatRaml = {
391396
classifier: "fat-raml",
392397
packaging: "zip",
393-
externalLink: "https://short.url/raml.zip",
398+
externalLink: "https://short.url/test",
394399
createdDate: "2020-02-05T21:26:01.199Z",
395400
md5: "87b3ad2b2aa17639b52f0cc83c5a8d40",
396401
sha1: "f2b9b2de50b7250616e2eea8843735b57235c22b",

src/download/exchangeDownloader.ts

Lines changed: 126 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright (c) 2021, salesforce.com, inc.
2+
* Copyright (c) 2025, salesforce.com, inc.
33
* All rights reserved.
44
* SPDX-License-Identifier: BSD-3-Clause
55
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
@@ -30,6 +30,8 @@ const ANYPOINT_API_URI_V2 = `${ANYPOINT_BASE_URI}/api/v2`;
3030
const DEPLOYMENT_DEPRECATION_WARNING =
3131
"The 'deployment' argument is deprecated. The latest RAML specification that is published to Anypoint Exchange will be downloaded always.";
3232

33+
// Only allows MAJOR.MINOR.PATCH (no suffixes). see https://semver.org/
34+
const releaseSemverRegex = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
3335
/**
3436
* Makes an HTTP call to the url with the options passed. If the calls due to
3537
* a 5xx, 408, 420 or 429, it retries the call with the retry options passed
@@ -73,7 +75,15 @@ export async function downloadRestApi(
7375
}
7476
try {
7577
await fs.ensureDir(destinationFolder);
76-
const zipFilePath = path.join(destinationFolder, `${restApi.assetId}.zip`);
78+
let zipFilePath = path.join(destinationFolder, `${restApi.assetId}.zip`);
79+
80+
if (isOas) {
81+
//For OAS, download clean latest versions from multiple version groups
82+
zipFilePath = path.join(
83+
destinationFolder,
84+
`${restApi.assetId}-${restApi.version}.zip`
85+
);
86+
}
7787

7888
const fatRaml = restApi.fatRaml;
7989
const fatOas = restApi.fatOas;
@@ -104,6 +114,7 @@ export async function downloadRestApi(
104114
* Download the API specifications
105115
* @param restApi - Metadata of the API
106116
* @param destinationFolder - Destination directory for the download
117+
* @param isOas - True for Open Api Specification
107118
*/
108119
export async function downloadRestApis(
109120
restApi: RestApi[],
@@ -204,8 +215,9 @@ export async function searchExchange(
204215
accessToken: string,
205216
searchString: string
206217
): Promise<RestApi[]> {
218+
//TODO: We may have to handle pagination in the future if the number of APIs returned is more than 50
207219
return runFetch(
208-
`${ANYPOINT_API_URI_V2}/assets?search=${searchString}&types=rest-api`,
220+
`${ANYPOINT_API_URI_V2}/assets?search=${searchString}&types=rest-api&limit=50&offset=0`,
209221
{
210222
headers: {
211223
Authorization: `Bearer ${accessToken}`,
@@ -299,11 +311,13 @@ export async function getSpecificApi(
299311
* @param query - Exchange search query
300312
* @param [deployment] - RegExp matching the desired deployment targets
301313
*
314+
* @param isOas - True to get Open API Specifications, false for RAML
302315
* @returns Information about the APIs found.
303316
*/
304317
export async function search(
305318
query: string,
306-
deployment?: RegExp
319+
deployment?: RegExp,
320+
isOas = false
307321
): Promise<RestApi[]> {
308322
if (deployment) {
309323
ramlToolLogger.warn(DEPLOYMENT_DEPRECATION_WARNING);
@@ -314,6 +328,9 @@ export async function search(
314328
process.env.ANYPOINT_PASSWORD
315329
);
316330
const apis = await searchExchange(token, query);
331+
if (isOas) {
332+
return getLatestCleanApis(apis, token);
333+
}
317334
const promises = apis.map(async (api) => {
318335
const version = await getVersionByDeployment(token, api, deployment);
319336
return version
@@ -322,3 +339,108 @@ export async function search(
322339
});
323340
return Promise.all(promises);
324341
}
342+
343+
/**
344+
* Gets information about all the APIs from exchange that match the given search
345+
* string.
346+
* If it fails to get information about the deployed version of an API, it
347+
* removes all the version specific information from the returned object.
348+
*
349+
* @param apis - Array of apis to get the latest versions
350+
* @param {string} accessToken
351+
*
352+
* @returns Information about the APIs found.
353+
*/
354+
export async function getLatestCleanApis(
355+
apis: RestApi[],
356+
accessToken: string
357+
): Promise<RestApi[]> {
358+
// Get all API versions in parallel
359+
const apiVersionPromises = apis.map(async (api) => {
360+
const versions = await getLatestCleanApiVersions(accessToken, api);
361+
if (!versions || versions.length === 0) {
362+
return { api, versions: [] };
363+
}
364+
return { api, versions };
365+
});
366+
367+
const allApiVersions = await Promise.all(apiVersionPromises);
368+
// Create promises for all API versions and process them in parallel
369+
const promises = [];
370+
for (const { api, versions } of allApiVersions) {
371+
for (const version of versions) {
372+
promises.push(
373+
getSpecificApi(accessToken, api.groupId, api.assetId, version)
374+
);
375+
}
376+
}
377+
return Promise.all(promises);
378+
}
379+
380+
/**
381+
* @description Returns the latest clean (MAJOR.MINOR.PATCH) API versions from multiple version groups (V1, V2..) of an API
382+
*
383+
* @export
384+
* @param {string} accessToken
385+
* @param {RestApi} restApi
386+
* @returns {Promise<string>} Returned the version string from the instance fetched asset.version value
387+
*/
388+
export async function getLatestCleanApiVersions(
389+
accessToken: string,
390+
restApi: RestApi
391+
): Promise<void | string[]> {
392+
const logPrefix = "[exchangeDownloader][getLatestCleanApiVersions]";
393+
394+
let asset: void | RawRestApi;
395+
try {
396+
asset = await getAsset(
397+
accessToken,
398+
`${restApi.groupId}/${restApi.assetId}`
399+
);
400+
} catch (error) {
401+
ramlToolLogger.error(`${logPrefix} Error fetching asset:`, error);
402+
return;
403+
}
404+
405+
if (!asset) {
406+
ramlToolLogger.log(
407+
`${logPrefix} No asset found for ${restApi.assetId}, returning`
408+
);
409+
return;
410+
}
411+
412+
if (!asset.versionGroups) {
413+
ramlToolLogger.error(
414+
`${logPrefix} The rest API ${restApi.assetId} is missing asset.versionGroups`
415+
);
416+
return;
417+
}
418+
const versions: string[] = [];
419+
asset.versionGroups.forEach((versionGroup) => {
420+
const version = getLatestReleaseVersion(versionGroup);
421+
if (version) {
422+
versions.push(version);
423+
}
424+
});
425+
return versions;
426+
}
427+
428+
function getLatestReleaseVersion(versionGroup: {
429+
versions: Array<{ version: string }>;
430+
}): void | string {
431+
if (!versionGroup.versions || versionGroup.versions.length === 0) {
432+
return;
433+
}
434+
const releaseAssetVersions = versionGroup.versions.filter((version) => {
435+
return releaseSemverRegex.test(version.version);
436+
});
437+
// Sort versions and get the latest
438+
return releaseAssetVersions.sort((instanceA, instanceB) => {
439+
const [aMajor, aMinor, aPatch] = instanceA.version.split(".").map(Number);
440+
const [bMajor, bMinor, bPatch] = instanceB.version.split(".").map(Number);
441+
442+
if (aMajor !== bMajor) return bMajor - aMajor;
443+
if (aMinor !== bMinor) return bMinor - aMinor;
444+
return bPatch - aPatch;
445+
})[0].version;
446+
}

0 commit comments

Comments
 (0)