scripts.v3/utils.js (401 lines of code) (raw):
const fs = require("fs");
const path = require("path");
const https = require("https");
const { execSync } = require("child_process");
const { BlobServiceClient } = require("@azure/storage-blob");
const blobStorageContainer = "content";
const mime = require("mime");
const apiVersion = "2021-08-01"; //"2020-06-01-preview";
const managementApiEndpoint = "management.azure.com";
const metadataFileExt = ".info";
const defaultFileEncoding = "utf8";
class HttpClient {
constructor(subscriptionId, resourceGroupName, serviceName, tenantId, servicePrincipal, secret) {
this.subscriptionId = subscriptionId;
this.resourceGroupName = resourceGroupName;
this.serviceName = serviceName;
this.baseUrl = `https://${managementApiEndpoint}/subscriptions/${subscriptionId}/resourceGroups/${resourceGroupName}/providers/Microsoft.ApiManagement/service/${serviceName}`;
this.accessToken = this.getAccessToken(tenantId, servicePrincipal, secret);
}
/**
* A wrapper for making a request and returning its response body.
* @param {string} method - Http method, e.g. GET.
* @param {string} url - Relative resource URL, e.g. `/contentTypes`.
* @param {string} body - Request body.
*/
async sendRequest(method, url, body) {
let requestUrl;
let requestBody;
if (url.startsWith("https://")) {
requestUrl = new URL(url);
} else {
const normalizedUrl = url.startsWith("/") ? url : `/${url}`;
requestUrl = new URL(this.baseUrl + normalizedUrl);
}
if (!requestUrl.searchParams.has("api-version")) {
requestUrl.searchParams.append("api-version", apiVersion);
}
const headers = {
"If-Match": "*",
"Content-Type": "application/json",
"Authorization": this.accessToken
};
if (body) {
if (!body.properties) {
body = {
properties: body
}
}
requestBody = JSON.stringify(body);
headers["Content-Length"] = Buffer.byteLength(requestBody);
}
const options = {
port: 443,
method: method,
headers: headers
};
return new Promise((resolve, reject) => {
const req = https.request(requestUrl.toString(), options, (resp) => {
let chunks = [];
resp.on('data', (chunk) => {
chunks.push(chunk);
});
resp.on('end', () => {
let data = Buffer.concat(chunks).toString('utf8');
switch (resp.statusCode) {
case 200:
case 201:
case 202:
data.startsWith("{") ? resolve(JSON.parse(data)) : resolve(data);
break;
case 404:
reject({ code: "NotFound", message: `Resource not found: ${requestUrl}` });
break;
case 401:
reject({ code: "Unauthorized", message: `Unauthorized. Make sure you're logged-in with "az login" command before running the script.` });
break;
case 403:
reject({ code: "Forbidden", message: `Looks like you are not allowed to perform this operation. Please check with your administrator.` });
break;
default:
reject({ code: "UnhandledError", message: `Could not complete request to ${requestUrl}. Status: ${resp.statusCode} ${resp.statusMessage}` });
}
});
});
req.on('error', (e) => {
reject(e);
});
if (requestBody) {
req.write(requestBody);
}
req.end();
});
}
getAccessToken(tenantId, servicePrincipal, secret) {
if (tenantId != "" && tenantId != null) {
execSync(`az login --service-principal --username ` + servicePrincipal + ` --password ` + secret + ` --tenant ` + tenantId);
}
const accessToken = execSync(`az account get-access-token --resource-type arm --output tsv --query accessToken`).toString().trim();
return `Bearer ${accessToken}`;
}
}
class ImporterExporter {
constructor(subscriptionId, resourceGroupName, serviceName, tenantId, servicePrincipal, secret, snapshotFolder = "../dist/snapshot") {
this.httpClient = new HttpClient(subscriptionId, resourceGroupName, serviceName, tenantId, servicePrincipal, secret);
this.snapshotFolder = snapshotFolder
}
/**
* Returns list of files in specified directory and its sub-directories.
* @param {string} dir - Directory, e.g. "./dist/snapshot".
*/
listFilesInDirectory(dir) {
const results = [];
fs.readdirSync(dir).forEach((file) => {
if (file.endsWith(".info")) {
return;
}
file = dir + "/" + file;
const stat = fs.statSync(file);
if (stat && stat.isDirectory()) {
results.push(...this.listFilesInDirectory(file));
}
else {
results.push(file);
}
});
return results;
}
/**
* Returns list of content types.
*/
async getContentTypes() {
try {
const data = await this.httpClient.sendRequest("GET", `/contentTypes`);
const contentTypes = data.value.map(x => x.id.replace("\/contentTypes\/", ""));
return contentTypes;
}
catch (error) {
throw new Error(`Unable to fetch content types. ${error.message}`);
}
}
/**
* Returns list of content items of specified content type.
* @param {string} contentType - Content type, e.g. "page".
*/
async getContentItems(contentType) {
try {
const contentItems = [];
let nextPageUrl = `/contentTypes/${contentType}/contentItems`;
nextPageUrl = this.ensureDocumentTypeFiltered(contentType, nextPageUrl);
do {
const data = await this.httpClient.sendRequest("GET", nextPageUrl);
contentItems.push(...data.value);
if (data.value.length > 0 && data.nextLink) {
nextPageUrl = data.nextLink;
} else {
nextPageUrl = null;
}
}
while (nextPageUrl)
return contentItems;
}
catch (error) {
throw new Error(`Unable to fetch content items. ${error.message}`);
}
}
ensureDocumentTypeFiltered(contentType, nextLink) {
if (contentType === 'document') {
nextLink = `${nextLink}?$top=1`
}
return nextLink
}
/**
* Returns a single content item of specified content type.
* @param {string} contentType - Content type, e.g. "page".
* @param {string} contentItem - Content item, e.g. "configuration".
*/
async getContentItem(contentType, contentItem) {
try {
const url = `/contentTypes/${contentType}/contentItems/${contentItem}`;
const data = await this.httpClient.sendRequest("GET", url);
return data;
}
catch (error) {
throw new Error(`Unable to fetch content item. ${error.message}`);
}
}
/**
* Updates a single content item of specified content type.
* @param {string} contentType - Content type, e.g. "page".
* @param {string} contentItem - Content item, e.g. "configuration".
* @param {object} body Request body .
*/
async updateContentItem(contentType, contentItem, body) {
try {
const url = `/contentTypes/${contentType}/contentItems/${contentItem}`;
const data = await this.httpClient.sendRequest("PUT", url, body);
return data;
}
catch (error) {
throw new Error(`Unable to update content item. ${error.message}`);
}
}
/**
* Downloads media files from storage of specified API Management service.
*/
async downloadBlobs() {
try {
const snapshotMediaFolder = `${this.snapshotFolder}/media`;
const blobStorageUrl = await this.getStorageSasUrl();
const blobServiceClient = new BlobServiceClient(blobStorageUrl.replace(`/${blobStorageContainer}`, ""));
const containerClient = blobServiceClient.getContainerClient(blobStorageContainer);
await this.downloadBlobsRecursive(containerClient, snapshotMediaFolder);
}
catch (error) {
throw new Error(`Unable to download media files. ${error.message}`);
}
}
async downloadBlobsRecursive(containerClient, outputFolder, prefix = undefined) {
let blobs = containerClient.listBlobsByHierarchy("/", prefix ? { prefix: prefix } : undefined);
for await (const blob of blobs) {
if (blob.kind === "prefix") {
await this.downloadBlobsRecursive(containerClient, outputFolder, blob.name);
continue;
}
const blockBlobClient = containerClient.getBlockBlobClient(blob.name);
const pathToFile = `${outputFolder}/${blob.name}`;
const folderPath = pathToFile.substring(0, pathToFile.lastIndexOf("/"));
await fs.promises.mkdir(path.resolve(folderPath), { recursive: true });
await blockBlobClient.downloadToFile(pathToFile);
const metadata = { contentType: blob.properties.contentType };
const metadataFile = JSON.stringify(metadata);
await fs.promises.writeFile(pathToFile + metadataFileExt, metadataFile);
}
}
/**
* Uploads media files to storage of specified API Management service.
*/
async uploadBlobs() {
const snapshotMediaFolder = `${this.snapshotFolder}/media`;
if (!fs.existsSync(snapshotMediaFolder)) {
console.info("No media files found in the snapshot folder. Skipping media upload...");
return;
}
try {
const blobStorageUrl = await this.getStorageSasUrl();
const blobServiceClient = new BlobServiceClient(blobStorageUrl.replace(`/${blobStorageContainer}`, ""));
const containerClient = blobServiceClient.getContainerClient(blobStorageContainer);
const fileNames = this.listFilesInDirectory(snapshotMediaFolder);
for (const fileName of fileNames) {
let contentType;
let blobKey = fileName.replace(snapshotMediaFolder + "/", "")
const metadataFilePath = fileName + metadataFileExt;
if (fs.existsSync(metadataFilePath)) {
const metadataFile = await fs.promises.readFile(metadataFilePath, defaultFileEncoding);
const metadata = JSON.parse(metadataFile);
contentType = metadata.contentType
} else {
blobKey = blobKey.split(".")[0];
contentType = mime.getType(fileName) || "application/octet-stream";
}
const blockBlobClient = containerClient.getBlockBlobClient(blobKey);
await blockBlobClient.uploadFile(fileName, {
blobHTTPHeaders: {
blobContentType: contentType
}
});
}
}
catch (error) {
throw new Error(`Unable to upload media files. ${error.message}`);
}
}
/**
* Deletes media files from storage of specified API Management service.
*/
async deleteBlobs() {
try {
const blobStorageUrl = await this.getStorageSasUrl();
const blobServiceClient = new BlobServiceClient(blobStorageUrl.replace(`/${blobStorageContainer}`, ""));
const containerClient = blobServiceClient.getContainerClient(blobStorageContainer);
let blobs = containerClient.listBlobsFlat();
for await (const blob of blobs) {
const blockBlobClient = containerClient.getBlockBlobClient(blob.name);
await blockBlobClient.delete();
}
}
catch (error) {
throw new Error(`Unable to delete media files. ${error.message}`);
}
}
/**
* Captures the content of specified API Management service into snapshot.
*/
async captureContent() {
try {
const result = {};
const contentTypes = await this.getContentTypes();
for (const contentType of contentTypes) {
const contentItems = await this.getContentItems(contentType);
contentItems.forEach(contentItem => {
result[contentItem.id] = contentItem;
delete contentItem.id;
});
}
await fs.promises.mkdir(path.resolve(this.snapshotFolder), { recursive: true });
fs.writeFileSync(`${this.snapshotFolder}/data.json`, JSON.stringify(result));
}
catch (error) {
throw new Error(`Unable to capture content. ${error.message}`);
}
}
/**
* Deletes the content in specified API Management service.
*/
async deleteContent() {
try {
const contentTypes = await this.getContentTypes();
for (const contentType of contentTypes) {
const contentItems = await this.getContentItems(contentType);
for (const contentItem of contentItems) {
await this.httpClient.sendRequest("DELETE", `/${contentItem.id}`);
}
}
}
catch (error) {
throw new Error(`Unable to delete content. ${error.message}`);
}
}
/**
* Generates the content in specified API Management service from snapshot.
*/
async generateContent() {
const snapshotFilePath = `${this.snapshotFolder}/data.json`;
try {
if (!fs.existsSync(snapshotFilePath)) {
throw new Error(`Snapshot file ${snapshotFilePath} not found.`);
}
const data = fs.readFileSync(snapshotFilePath);
const dataObj = JSON.parse(data);
const keys = Object.keys(dataObj);
for (const key of keys) {
await this.httpClient.sendRequest("PUT", key, dataObj[key]);
}
}
catch (error) {
throw new Error(`Unable to generate the content. ${error.message}`);
}
}
/**
* Gets a storage SAS URL.
*/
async getStorageSasUrl() {
const response = await this.httpClient.sendRequest("POST", `/portalSettings/mediaContent/listSecrets`);
return response.containerSasUrl;
}
/**
* Deletes the content and media files in specfied service.
*/
async cleanup() {
console.log("Cleaning up...")
try {
await this.deleteContent();
await this.deleteBlobs();
}
catch (error) {
throw new Error(`Unable to complete cleanup. ${error.message}`);
}
}
/**
* Exports the content and media files from specfied service.
*/
async export() {
console.log("Exporting...")
try {
await this.captureContent();
await this.downloadBlobs();
}
catch (error) {
throw new Error(`Unable to complete export. ${error.message}`);
}
}
/**
* Imports the content and media files into specfied service.
*/
async import() {
console.log("Importing...")
try {
await this.generateContent();
await this.uploadBlobs();
}
catch (error) {
throw new Error(`Unable to complete import. ${error.message}`);
}
}
/**
* Publishes the content of the specified APIM service.
*/
async publish() {
try {
const timeStamp = new Date();
const revision = timeStamp.toISOString().replace(/[\-\:\T]/g, "").substr(0, 14);
const url = `/portalRevisions/${revision}`;
const body = {
description: `Migration ${revision}.`,
isCurrent: true
};
await this.httpClient.sendRequest("PUT", url, body);
}
catch (error) {
throw new Error(`Unable to schedule website publishing. ${error.message}`);
}
}
/**
* Replaces existing URLs of API Management service with specified URLs.
*/
async updateContentUrl(existingUrls, replaceableUrls) {
try {
if (existingUrls.Count != replaceableUrls.Count) {
throw new Error(`Existing URL and Replaceable URLs count mismatch.`);
}
const contentItems = await this.getContentItems("url");
console.log("Number of urls found in portal: " + contentItems.length);
for (const contentItem of contentItems) {
var count = 0;
console.log(" url found in portal: " + contentItem.properties.permalink);
for (const existingUrl of existingUrls) {
if (contentItem.properties.permalink == existingUrl) {
contentItem.properties.permalink = replaceableUrls[count];
console.log("updating URL content... for no. " + count + " link: " + contentItem.properties.permalink);
console.log(" updated URL content : for no. " + count + " content item: " + JSON.stringify(contentItem));
const response = await this.httpClient.sendRequest("PUT", contentItem.id + "?api-version=" + apiVersion, contentItem);
console.log(" response : " + JSON.stringify(response));
}
count++;
};
};
}
catch (error) {
throw new Error(`Unable to update URL. ${error.message}`);
}
}
/**
* Pushes the GTM tag to the specified APIM instance.
* @param {string} gtmContainerId - Google Tag Manager container ID, e.g. `GTM-XXXXXX`.
*/
async gtm(gtmContainerId) {
console.log("Applying GTM Tag...")
try {
const config = await this.getContentItem("document", "configuration");
const newNodes = config.properties.nodes.map((node) => {
return {
...node, integration: {
googleTagManager: {
containerId: gtmContainerId
}
}
}
})
const newConfig = { ...config, properties: { ...config.properties, nodes: newNodes } }
await this.updateContentItem("document", "configuration", newConfig)
}
catch (error) {
throw new Error(`Unable to apply gtm tag. ${error.message}`);
}
}
}
module.exports = {
ImporterExporter
};