Skip to content
Merged
139 changes: 46 additions & 93 deletions src/download/exchangeDownloader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
downloadRestApi,
downloadRestApis,
searchExchange,
getVersionByDeployment,
getApiVersions,
getSpecificApi,
getAsset,
runFetch,
Expand All @@ -34,10 +34,10 @@ const assetSearchResults = require("../../testResources/download/resources/asset
const getAssetWithVersion = require("../../testResources/download/resources/getAssetWithVersion");

// eslint-disable-next-line @typescript-eslint/no-var-requires
const getAssetWithVersionV2 = require("../../testResources/download/resources/getAssetWithVersionV2");
const getAssetWithoutVersion = require("../../testResources/download/resources/getAsset");

// eslint-disable-next-line @typescript-eslint/no-var-requires
const getAssetWithoutVersion = require("../../testResources/download/resources/getAsset");
const getAssetWithMultipleVersionGroups = require("../../testResources/download/resources/getAssetWithMultipleVersionGroups.json");

const REST_API: RestApi = {
id: "8888888/test-api/1.0.0",
Expand Down Expand Up @@ -234,50 +234,56 @@ describe("exchangeDownloader", () => {
});
});

describe("getVersionByDeployment", () => {
describe("getApiVersions", () => {
const scope = nock("https://anypoint.mulesoft.com/exchange/api/v1/assets");

it("should return a version if no deployment is specified", async () => {
it("should return the latest version", async () => {
scope.get("/8888888/test-api").reply(200, getAssetWithoutVersion);

return expect(
getVersionByDeployment("AUTH_TOKEN", REST_API)
).to.eventually.equal("0.0.7");
getApiVersions("AUTH_TOKEN", REST_API)
).to.eventually.deep.equal(["0.1.1"]);
});

it("should return a version if a deployment exists", async () => {
scope.get("/8888888/test-api").reply(200, getAssetWithoutVersion);
it("should return undefined if the asset does not exist", async () => {
scope.get("/8888888/test-api").reply(404, "Not Found");

return expect(
getVersionByDeployment("AUTH_TOKEN", REST_API, /production/i)
).to.eventually.equal("0.0.7");
return expect(getApiVersions("AUTH_TOKEN", REST_API)).to.eventually.be
.undefined;
});

it("should return the base version if the deployment does not exist", async () => {
scope.get("/8888888/test-api").reply(200, getAssetWithoutVersion);
it("should return undefined if the asset does not have a version groups", async () => {
const assetWithoutVersion = _.cloneDeep(getAssetWithoutVersion);
delete assetWithoutVersion.versionGroups;

return expect(
getVersionByDeployment("AUTH_TOKEN", REST_API, /NOT AVAILABLE/i)
).to.eventually.equal(getAssetWithoutVersion.version);
scope.get("/8888888/test-api").reply(200, assetWithoutVersion);

return expect(getApiVersions("AUTH_TOKEN", REST_API)).to.eventually.be
.undefined;
});

it("should return undefined if the asset does not exist", async () => {
scope.get("/8888888/test-api").reply(404, "Not Found");
it("should return latest versions from all the version groups", async () => {
scope
.get("/8888888/test-api")
.reply(200, getAssetWithMultipleVersionGroups);

return expect(
getVersionByDeployment("AUTH_TOKEN", REST_API, /NOT AVAILABLE/i)
).to.eventually.be.undefined;
getApiVersions("AUTH_TOKEN", REST_API)
).to.eventually.deep.equal(["2.0.10", "1.8.19"]);
});

it("should return undefined if the asset does not have a version", async () => {
const assetWithoutVersion = _.cloneDeep(getAssetWithoutVersion);
delete assetWithoutVersion.version;
it("should return undefined if the version groups does not have a version", async () => {
const assetWithoutVersion = _.cloneDeep(
getAssetWithMultipleVersionGroups
);
delete assetWithoutVersion.versionGroups[0].versions;
delete assetWithoutVersion.versionGroups[1].versions;

scope.get("/8888888/test-api").reply(200, assetWithoutVersion);

return expect(
getVersionByDeployment("AUTH_TOKEN", REST_API, /NOT AVAILABLE/i)
).to.eventually.be.undefined;
getApiVersions("AUTH_TOKEN", REST_API)
).to.eventually.deep.equal([]);
});
});

Expand Down Expand Up @@ -339,91 +345,38 @@ describe("exchangeDownloader", () => {
.reply(200, [assetSearchResults[0]]);
});

it("searches Exchange and filters by deployment", () => {
it("searches Exchange and filters by latest version", () => {
scope
.get("/shop-products-categories-api-v1")
.reply(200, getAssetWithVersion)
.get("/shop-products-categories-api-v1/0.0.1")
.get("/shop-products-categories-api-v1/0.1.1")
.reply(200, getAssetWithVersion);

return expect(search("searchString")).to.eventually.deep.equal([
shopperCustomersAsset,
shopperCustomersAsset,
]);
});

it("works when an asset does not exist", () => {
scope.get("/shop-products-categories-api-v1").reply(404, "Not Found");

return expect(search("searchString")).to.eventually.deep.equal([
{
id: null,
name: "Shopper Products",
description:
"Enable developers to add functionality that shows product details in shopping apps.",
updatedDate: null,
groupId: "893f605e-10e2-423a-bdb4-f952f56eb6d8",
assetId: "shop-products-categories-api-v1",
version: null,
categories: {
"API layer": ["Process"],
"CC API Family": ["Product"],
"CC Version Status": ["Beta"],
"CC API Visibility": ["External"],
},
fatRaml: {
classifier: "fat-raml",
packaging: "zip",
createdDate: null,
md5: null,
sha1: null,
mainFile: "shop-products-categories-api-v1.raml",
},
fatOas: null,
},
]);
});

it("works when there are no matches for the specified deployment", () => {
const asset = _.cloneDeep(shopperCustomersAsset);
asset.id = "893f605e-10e2-423a-bdb4-f952f56eb6d8/shopper-customers/0.0.7";
asset.version = "0.0.7";
asset.fatRaml = {
classifier: "fat-raml",
packaging: "zip",
externalLink: "https://short.url/raml.zip",
createdDate: "2020-02-05T21:26:01.199Z",
md5: "87b3ad2b2aa17639b52f0cc83c5a8d40",
sha1: "f2b9b2de50b7250616e2eea8843735b57235c22b",
mainFile: "shopper-customers.raml",
};

scope
.get("/shop-products-categories-api-v1")
.reply(200, getAssetWithoutVersion)
.get("/shop-products-categories-api-v1/0.0.7")
.reply(200, getAssetWithoutVersion);

return expect(search("searchString")).to.eventually.deep.equal([asset]);
return expect(search("searchString")).to.eventually.deep.equal([]);
});

/**
* Returns root asset version when production tag is not found
* on V2 API response. The actual deployed version is available
* under otherVersions attributes, that has no external link to download
* and no environmentName attribute to match
*/
it("returns the root asset version without production tag on V2 response", () => {
const asset = _.cloneDeep(shopperCustomersAsset);
asset.id = "893f605e-10e2-423a-bdb4-f952f56eb6d8/shopper-customers/0.5.0";
asset.version = "0.5.0";
asset.fatRaml.externalLink = "https://somewhere/fatraml.zip";
it("searches Exchange and returns multiple version groupd", () => {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Suggested change
it("searches Exchange and returns multiple version groupd", () => {
it("searches Exchange and returns multiple version groups", () => {

scope
.get("/shop-products-categories-api-v1")
.reply(200, getAssetWithVersionV2)
.get("/shop-products-categories-api-v1/0.5.0")
.reply(200, getAssetWithVersionV2);
.reply(200, getAssetWithMultipleVersionGroups)
.get("/shop-products-categories-api-v1/1.8.19")
.reply(200, getAssetWithVersion)
.get("/shop-products-categories-api-v1/2.0.10")
.reply(200, getAssetWithVersion);

return expect(search("searchString")).to.eventually.deep.equal([asset]);
return expect(search("searchString")).to.eventually.deep.equal([
shopperCustomersAsset,
shopperCustomersAsset,
]);
});
});

Expand Down
88 changes: 55 additions & 33 deletions src/download/exchangeDownloader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import fs from "fs-extra";
import path from "path";

import { getBearer } from "./bearerToken";
import { removeVersionSpecificInformation } from "./exchangeTools";
import {
RawRestApi,
RestApi,
Expand All @@ -27,9 +26,8 @@ export const DEFAULT_DOWNLOAD_FOLDER = "download";
const ANYPOINT_BASE_URI = "https://anypoint.mulesoft.com/exchange";
const ANYPOINT_API_URI_V1 = `${ANYPOINT_BASE_URI}/api/v1`;
const ANYPOINT_API_URI_V2 = `${ANYPOINT_BASE_URI}/api/v2`;
const DEPLOYMENT_DEPRECATION_WARNING =
"The 'deployment' argument is deprecated. The latest RAML specification that is published to Anypoint Exchange will be downloaded always.";

// Only allows MAJOR.MINOR.PATCH (no suffixes). see https://semver.org/
const releaseSemverRegex = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
/**
* Makes an HTTP call to the url with the options passed. If the calls due to
* a 5xx, 408, 420 or 429, it retries the call with the retry options passed
Expand Down Expand Up @@ -73,7 +71,10 @@ export async function downloadRestApi(
}
try {
await fs.ensureDir(destinationFolder);
const zipFilePath = path.join(destinationFolder, `${restApi.assetId}.zip`);
const zipFilePath = path.join(
destinationFolder,
`${restApi.assetId}-${restApi.version}.zip`
);

const fatRaml = restApi.fatRaml;
const fatOas = restApi.fatOas;
Expand Down Expand Up @@ -104,6 +105,7 @@ export async function downloadRestApi(
* Download the API specifications
* @param restApi - Metadata of the API
* @param destinationFolder - Destination directory for the download
* @param isOas - True for Open Api Specification
*/
export async function downloadRestApis(
restApi: RestApi[],
Expand Down Expand Up @@ -228,20 +230,15 @@ export async function searchExchange(
* @export
* @param {string} accessToken
* @param {RestApi} restApi
* @param {RegExp} [deployment]
* @returns {Promise<string>} Returned the version string from the instance fetched asset.version value
*/
export async function getVersionByDeployment(
export async function getApiVersions(
Copy link
Collaborator

Choose a reason for hiding this comment

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

It's not clear in the description or the function name that we get the latest version, we should rename this function to something like getLatestApiVersions and/or update the JS doc description

Copy link
Contributor

@vcua-mobify vcua-mobify Jun 9, 2025

Choose a reason for hiding this comment

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

Since getApiVersions is a new function, can we keep the old getVersionByDeployment still?
Would keeping getVersionByDeployment make this a non-breaking change?

Does raml-toolkit have other users aside from commerce-sdk / commerce-sdk-isomorphic? This change would break those, if they exists.

Also, if we're going to be making a breaking change here, are there other breaking changes we also want to get in?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

We marked deployment flag as deprecated a while back. So I think it is ok to change the function name.
The raml-toolkit is only used by commerce-sdk / commerce-sdk-isomorphic

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Refactored to put all the new code behind isOas flag

accessToken: string,
restApi: RestApi,
deployment?: RegExp
): Promise<void | string> {
if (deployment) {
ramlToolLogger.warn(DEPLOYMENT_DEPRECATION_WARNING);
}
const logPrefix = "[exchangeDownloader][getVersion]";
restApi: RestApi
): Promise<void | string[]> {
const logPrefix = "[exchangeDownloader][getVersions]";

let asset;
let asset: void | RawRestApi;
try {
asset = await getAsset(
accessToken,
Expand All @@ -259,15 +256,40 @@ export async function getVersionByDeployment(
return;
}

if (!asset.version) {
if (!asset.versionGroups) {
ramlToolLogger.error(
`${logPrefix} The rest API ${restApi.assetId} is missing the asset.version`
`${logPrefix} The rest API ${restApi.assetId} is missing asset.versionGroups`
);
return;
}
const versions: string[] = [];
asset.versionGroups.forEach((versionGroup) => {
const version = getLatestReleaseVersion(versionGroup);
if (version) {
versions.push(version);
}
});
return versions;
}

// return the most recent version of an asset from the rest API
return asset.version;
function getLatestReleaseVersion(versionGroup: {
versions: Array<{ version: string }>;
}): void | string {
if (!versionGroup.versions || versionGroup.versions.length === 0) {
return;
}
const releaseAssetVersions = versionGroup.versions.filter((version) => {
return releaseSemverRegex.test(version.version);
});
// Sort versions and get the latest
return releaseAssetVersions.sort((instanceA, instanceB) => {
Copy link
Collaborator

Choose a reason for hiding this comment

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

This function is a nice solution, it feels like a very clean way to get the latest version, straightforward to understand

const [aMajor, aMinor, aPatch] = instanceA.version.split(".").map(Number);
const [bMajor, bMinor, bPatch] = instanceB.version.split(".").map(Number);

if (aMajor !== bMajor) return bMajor - aMajor;
if (aMinor !== bMinor) return bMinor - aMinor;
return bPatch - aPatch;
})[0].version;
}

/**
Expand Down Expand Up @@ -297,28 +319,28 @@ export async function getSpecificApi(
* removes all the version specific information from the returned object.
*
* @param query - Exchange search query
* @param [deployment] - RegExp matching the desired deployment targets
*
* @returns Information about the APIs found.
*/
export async function search(
query: string,
deployment?: RegExp
): Promise<RestApi[]> {
if (deployment) {
ramlToolLogger.warn(DEPLOYMENT_DEPRECATION_WARNING);
}

export async function search(query: string): Promise<RestApi[]> {
Copy link
Collaborator

Choose a reason for hiding this comment

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

We're planning to search based on the API SDK Type and Visibility categories/types correct? Previously we queried by name... How does it look now when we call search function in the SDKs?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

For node sdk, it will be
await download.search( 'category:Visibility = "External" category:"SDK Type" = "Commerce"' );

For isomorphic sdk, it will be
await download.search( 'category:Visibility = "External" category:"SDK Type" = "Commerce"' category:"SDK Type" = "Isomorphic"' );

const token = await getBearer(
process.env.ANYPOINT_USERNAME,
process.env.ANYPOINT_PASSWORD
);
const apis = await searchExchange(token, query);

const promises = apis.map(async (api) => {
const version = await getVersionByDeployment(token, api, deployment);
return version
? getSpecificApi(token, api.groupId, api.assetId, version)
: removeVersionSpecificInformation(api);
const versions = await getApiVersions(token, api);
if (!versions || versions.length === 0) {
return [];
}
const versionPromises = versions.map((version) => {
return getSpecificApi(token, api.groupId, api.assetId, version);
});
return Promise.all(versionPromises);
});
return Promise.all(promises);

return Promise.all(promises).then((results) =>
results.reduce((acc, val) => acc.concat(val), [])
);
Copy link
Collaborator

Choose a reason for hiding this comment

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

This isn't immediately clear to me what this does, could you add a small comment?

}
7 changes: 4 additions & 3 deletions src/download/exchangeTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,10 @@ export type RawCategories = {
export type RawRestApi = Omit<RestApi, "categories" | "fatRaml"> & {
categories: RawCategories[];
files: FileInfo[];
instances: {
environmentName: string;
version: string;
versionGroups: {
versions: {
version: string;
}[];
}[];
};

Expand Down
Loading
Loading