From 2afe19fffd25fffb2df59677eedb807fed27c8e3 Mon Sep 17 00:00:00 2001 From: Yan Zhang Date: Fri, 31 Jul 2020 15:06:10 +0800 Subject: [PATCH] refactor to consume v2.2 metadata (#145) Signed-off-by: Yan Zhang --- src/DependencyManager.ts | 11 +- src/extension.ts | 10 +- src/handler/EditStartersHandler.ts | 182 -------------------------- src/handler/GenerateProjectHandler.ts | 22 ++-- src/handler/index.ts | 3 +- src/model/Metadata.ts | 44 +++++++ src/model/ServiceManager.ts | 101 +++++++------- src/model/index.ts | 2 +- 8 files changed, 112 insertions(+), 263 deletions(-) delete mode 100644 src/handler/EditStartersHandler.ts create mode 100644 src/model/Metadata.ts diff --git a/src/DependencyManager.ts b/src/DependencyManager.ts index ed3d698..add9616 100644 --- a/src/DependencyManager.ts +++ b/src/DependencyManager.ts @@ -2,14 +2,14 @@ // Licensed under the MIT license. import { QuickPickItem } from "vscode"; -import { IDependency, ServiceManager } from "./model"; +import { IDependency, serviceManager } from "./model"; import { readFileFromExtensionRoot, writeFileToExtensionRoot } from "./Utils"; const PLACEHOLDER: string = ""; const HINT_CONFIRM: string = "Press to continue."; const DEPENDENCIES_HISTORY_FILENAME: string = ".last_used_dependencies"; -class DependencyManager { +export class DependencyManager { public lastselected: string = null; public dependencies: IDependency[] = []; @@ -30,9 +30,9 @@ class DependencyManager { this.lastselected = idList; } - public async getQuickPickItems(manager: ServiceManager, bootVersion: string, options?: { hasLastSelected: boolean }): Promise> { + public async getQuickPickItems(serviceUrl: string, bootVersion: string, options?: { hasLastSelected: boolean }): Promise> { if (this.dependencies.length === 0) { - await this.initialize(await manager.getAvailableDependencies(bootVersion)); + await this.initialize(await serviceManager.getAvailableDependencies(serviceUrl, bootVersion)); } const ret: Array = []; if (this.selectedIds.length === 0) { @@ -97,6 +97,3 @@ class DependencyManager { } export interface IDependenciesItem { itemType: string; id: string; } - -// tslint:disable-next-line:export-name -export const dependencyManager: DependencyManager = new DependencyManager(); diff --git a/src/extension.ts b/src/extension.ts index 7f2a5e0..7fcecb7 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -6,9 +6,9 @@ import * as vscode from "vscode"; import { dispose as disposeTelemetryWrapper, initializeFromJsonFile, - instrumentOperation, + instrumentOperation } from "vscode-extension-telemetry-wrapper"; -import { EditStartersHandler, GenerateProjectHandler } from "./handler"; +import { GenerateProjectHandler } from "./handler"; import { getTargetPomXml, loadPackageInfo } from "./Utils"; export async function activate(context: vscode.ExtensionContext): Promise { @@ -38,11 +38,11 @@ async function initializeExtension(_operationId: string, context: vscode.Extensi } })); - context.subscriptions.push(instrumentAndRegisterCommand("spring.initializr.editStarters", async (operationId: string, entry?: vscode.Uri) => { + context.subscriptions.push(instrumentAndRegisterCommand("spring.initializr.editStarters", async (_oid: string, entry?: vscode.Uri) => { + throw new Error("Not implemented"); const targetFile: vscode.Uri = entry || await getTargetPomXml(); if (targetFile) { - await vscode.window.showTextDocument(targetFile); - await new EditStartersHandler().run(operationId, targetFile); + // await vscode.window.showTextDocument(targetFile); } else { vscode.window.showInformationMessage("No pom.xml found in the workspace."); } diff --git a/src/handler/EditStartersHandler.ts b/src/handler/EditStartersHandler.ts deleted file mode 100644 index fc6e3f6..0000000 --- a/src/handler/EditStartersHandler.ts +++ /dev/null @@ -1,182 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT license. - -import * as fse from "fs-extra"; -import * as path from "path"; -import * as vscode from "vscode"; -import { setUserError } from "vscode-extension-telemetry-wrapper"; -import { dependencyManager, IDependenciesItem } from "../DependencyManager"; -import { - addBomNode, - addDependencyNode, - addRepositoryNode, - BomNode, - DependencyNode, - getBootVersion, - getDependencyNodes, - getParentReletivePath, - IBom, - IMavenId, - IRepository, - IStarters, - removeDependencyNode, - RepositoryNode, - ServiceManager, - XmlNode, -} from "../model"; -import { buildXmlContent, readXmlContent } from "../Utils"; -import { BaseHandler } from "./BaseHandler"; -import { specifyServiceUrl } from "./utils"; - -export class EditStartersHandler extends BaseHandler { - private serviceUrl: string; - private manager: ServiceManager; - - protected get failureMessage(): string { - return "Fail to edit starters."; - } - - public async runSteps(_operationId: string, entry: vscode.Uri): Promise { - const bootVersion: string = await searchForBootVersion(entry.fsPath); - if (!bootVersion) { - const ex = new Error("Not a valid Spring Boot project."); - setUserError(ex); - throw ex; - } - - const deps: string[] = []; // gid:aid - // Read pom.xml for $dependencies(gid, aid) - const content: Buffer = await fse.readFile(entry.fsPath); - const xml: { project: XmlNode } = await readXmlContent(content.toString()); - - getDependencyNodes(xml.project).forEach(elem => { - deps.push(`${elem.groupId[0]}:${elem.artifactId[0]}`); - }); - - this.serviceUrl = await specifyServiceUrl(); - if (this.serviceUrl === undefined) { - return; - } - this.manager = new ServiceManager(this.serviceUrl); - // [interaction] Step: Dependencies, with pre-selected deps - const starters: IStarters = await vscode.window.withProgress( - { location: vscode.ProgressLocation.Window }, - async (p) => { - p.report({ message: `Fetching metadata for version ${bootVersion} ...` }); - return await this.manager.getStarters(bootVersion); - }, - ); - - const oldStarterIds: string[] = []; - if (!starters.dependencies) { - await vscode.window.showErrorMessage("Unable to retrieve information of available starters."); - return; - } - - Object.keys(starters.dependencies).forEach(key => { - const elem: IMavenId = starters.dependencies[key]; - if (deps.indexOf(`${elem.groupId}:${elem.artifactId}`) >= 0) { - oldStarterIds.push(key); - } - }); - - dependencyManager.selectedIds = [].concat(oldStarterIds); - let current: IDependenciesItem = null; - do { - current = await vscode.window.showQuickPick( - dependencyManager.getQuickPickItems(this.manager, bootVersion), - { - ignoreFocusOut: true, - matchOnDescription: true, - matchOnDetail: true, - placeHolder: "Select dependencies.", - }, - ); - if (current && current.itemType === "dependency") { - dependencyManager.toggleDependency(current.id); - } - } while (current && current.itemType === "dependency"); - if (!current) { return; } - - // Diff deps for add/remove - const toRemove: string[] = oldStarterIds.filter(elem => dependencyManager.selectedIds.indexOf(elem) < 0); - const toAdd: string[] = dependencyManager.selectedIds.filter(elem => oldStarterIds.indexOf(elem) < 0); - if (toRemove.length + toAdd.length === 0) { - vscode.window.showInformationMessage("No changes."); - return; - } - const msgRemove: string = (toRemove && toRemove.length) ? `Removing: [${toRemove.map(d => dependencyManager.dict[d] && dependencyManager.dict[d].name).filter(Boolean).join(", ")}].` : ""; - const msgAdd: string = (toAdd && toAdd.length) ? `Adding: [${toAdd.map(d => dependencyManager.dict[d] && dependencyManager.dict[d].name).filter(Boolean).join(", ")}].` : ""; - const choice: string = await vscode.window.showWarningMessage(`${msgRemove} ${msgAdd} Proceed?`, "Proceed", "Cancel"); - if (choice !== "Proceed") { - return; - } - - // add spring-boot-starter if no selected starters - if (dependencyManager.selectedIds.length === 0) { - toAdd.push("spring-boot-starter"); - starters.dependencies["spring-boot-starter"] = { - artifactId: "spring-boot-starter", - groupId: "org.springframework.boot", - }; - } - // modify xml object - const newXml: { project: XmlNode } = getUpdatedPomXml(xml, starters, toRemove, toAdd); - - // re-generate a pom.xml - const output: string = buildXmlContent(newXml); - await fse.writeFile(entry.fsPath, output); - vscode.window.showInformationMessage("Pom file successfully updated."); - return; - } - -} - -function getUpdatedPomXml(xml: any, starters: IStarters, toRemove: string[], toAdd: string[]): { project: XmlNode } { - const ret: { project: XmlNode } = Object.assign({}, xml); - toRemove.forEach(elem => { - removeDependencyNode(ret.project, starters.dependencies[elem].groupId, starters.dependencies[elem].artifactId); - }); - toAdd.forEach(elem => { - const dep: IMavenId = starters.dependencies[elem]; - const newDepNode: DependencyNode = new DependencyNode(dep.groupId, dep.artifactId, dep.version, dep.scope); - - addDependencyNode(ret.project, newDepNode.node); - - if (dep.bom) { - const bom: IBom = starters.boms[dep.bom]; - const newBomNode: BomNode = new BomNode(bom.groupId, bom.artifactId, bom.version); - addBomNode(ret.project, newBomNode.node); - } - - if (dep.repository) { - const repo: IRepository = starters.repositories[dep.repository]; - const newRepoNode: RepositoryNode = new RepositoryNode(dep.repository, repo.name, repo.url, repo.snapshotEnabled); - addRepositoryNode(ret.project, newRepoNode.node); - } - - }); - return ret; -} - -async function searchForBootVersion(pomPath: string): Promise { - const content: Buffer = await fse.readFile(pomPath); - const { project: projectNode } = await readXmlContent(content.toString()); - const bootVersion: string = getBootVersion(projectNode); - if (bootVersion) { - return bootVersion; - } - - // search recursively in parent pom - const relativePath = getParentReletivePath(projectNode); - if (relativePath) { - let absolutePath = path.join(path.dirname(pomPath), relativePath); - if ((await fse.stat(absolutePath)).isDirectory()) { - absolutePath = path.join(absolutePath, "pom.xml"); - } - if (await fse.pathExists(absolutePath)) { - return await searchForBootVersion(absolutePath); - } - } - return undefined; -} diff --git a/src/handler/GenerateProjectHandler.ts b/src/handler/GenerateProjectHandler.ts index a0b75d5..6cad86d 100644 --- a/src/handler/GenerateProjectHandler.ts +++ b/src/handler/GenerateProjectHandler.ts @@ -6,9 +6,9 @@ import * as fse from "fs-extra"; import * as path from "path"; import * as vscode from "vscode"; import { instrumentOperationStep, sendInfo } from "vscode-extension-telemetry-wrapper"; -import { dependencyManager, IDependenciesItem } from "../DependencyManager"; +import { DependencyManager, IDependenciesItem } from "../DependencyManager"; import { OperationCanceledError } from "../Errors"; -import { IValue, ServiceManager } from "../model"; +import { IValue, serviceManager } from "../model"; import { artifactIdValidation, downloadFile, groupIdValidation } from "../Utils"; import { getFromInputBox, openDialogForFolder } from "../Utils/VSCodeUI"; import { BaseHandler } from "./BaseHandler"; @@ -24,7 +24,6 @@ export class GenerateProjectHandler extends BaseHandler { private bootVersion: string; private dependencies: IDependenciesItem; private outputUri: vscode.Uri; - private manager: ServiceManager; constructor(projectType: "maven-project" | "gradle-project") { super(); @@ -40,7 +39,6 @@ export class GenerateProjectHandler extends BaseHandler { // Step: service URL this.serviceUrl = await instrumentOperationStep(operationId, "serviceUrl", specifyServiceUrl)(); if (this.serviceUrl === undefined) { throw new OperationCanceledError("Service URL not specified."); } - this.manager = new ServiceManager(this.serviceUrl); // Step: language this.language = await instrumentOperationStep(operationId, "Language", specifyLanguage)(); @@ -59,12 +57,12 @@ export class GenerateProjectHandler extends BaseHandler { if (this.packaging === undefined) { throw new OperationCanceledError("Packaging not specified."); } // Step: bootVersion - this.bootVersion = await instrumentOperationStep(operationId, "BootVersion", specifyBootVersion)(this.manager); + this.bootVersion = await instrumentOperationStep(operationId, "BootVersion", specifyBootVersion)(this.serviceUrl); if (this.bootVersion === undefined) { throw new OperationCanceledError("BootVersion not specified."); } sendInfo(operationId, { bootVersion: this.bootVersion }); // Step: Dependencies - this.dependencies = await instrumentOperationStep(operationId, "Dependencies", specifyDependencies)(this.manager, this.bootVersion); + this.dependencies = await instrumentOperationStep(operationId, "Dependencies", specifyDependencies)(this.serviceUrl, this.bootVersion); sendInfo(operationId, { depsType: this.dependencies.itemType, dependencies: this.dependencies.id }); // Step: Choose target folder @@ -74,8 +72,6 @@ export class GenerateProjectHandler extends BaseHandler { // Step: Download & Unzip await instrumentOperationStep(operationId, "DownloadUnzip", downloadAndUnzip)(this.downloadUrl, this.outputUri.fsPath); - dependencyManager.updateLastUsedDependencies(this.dependencies); - // Open in new window const hasOpenFolder: boolean = (vscode.workspace.workspaceFolders !== undefined); const candidates: string[] = [ @@ -147,20 +143,21 @@ async function specifyPackaging(): Promise { return packaging && packaging.toLowerCase(); } -async function specifyBootVersion(manager: ServiceManager): Promise { +async function specifyBootVersion(serviceUrl: string): Promise { const bootVersion: { value: IValue, label: string } = await vscode.window.showQuickPick<{ value: IValue, label: string }>( // @ts-ignore - manager.getBootVersions().then(versions => versions.map(v => ({ value: v, label: v.name }))), + serviceManager.getBootVersions(serviceUrl).then(versions => versions.map(v => ({ value: v, label: v.name }))), { ignoreFocusOut: true, placeHolder: "Specify Spring Boot version." } ); return bootVersion && bootVersion.value && bootVersion.value.id; } -async function specifyDependencies(manager: ServiceManager, bootVersion: string): Promise { +async function specifyDependencies(serviceUrl: string, bootVersion: string): Promise { + const dependencyManager = new DependencyManager(); let current: IDependenciesItem = null; do { current = await vscode.window.showQuickPick( - dependencyManager.getQuickPickItems(manager, bootVersion, { hasLastSelected: true }), + dependencyManager.getQuickPickItems(serviceUrl, bootVersion, { hasLastSelected: true }), { ignoreFocusOut: true, placeHolder: "Search for dependencies.", matchOnDetail: true, matchOnDescription: true }, ); if (current && current.itemType === "dependency") { @@ -170,6 +167,7 @@ async function specifyDependencies(manager: ServiceManager, bootVersion: string) if (!current) { throw new OperationCanceledError("Canceled on dependency seletion."); } + dependencyManager.updateLastUsedDependencies(this.dependencies); return current; } diff --git a/src/handler/index.ts b/src/handler/index.ts index b035b13..bf8959b 100644 --- a/src/handler/index.ts +++ b/src/handler/index.ts @@ -1,7 +1,6 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT license. -import { EditStartersHandler } from "./EditStartersHandler"; import { GenerateProjectHandler } from "./GenerateProjectHandler"; -export { EditStartersHandler, GenerateProjectHandler }; +export { GenerateProjectHandler }; diff --git a/src/model/Metadata.ts b/src/model/Metadata.ts new file mode 100644 index 0000000..0de96bc --- /dev/null +++ b/src/model/Metadata.ts @@ -0,0 +1,44 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. + +/** + * See https://docs.spring.io/initializr/docs/current/reference/html/#api-guide + */ +export interface Metadata { + bootVersion: Category; + dependencies: Category; + packaging: Category; + javaVersion: Category; + language: Category; + type: Category; +} + +interface Nameable { + name: string; +} + +interface Identifiable extends Nameable { + id: string; +} + +interface Category { + default?: string; + values: T[]; +} + +interface ProjectType extends Identifiable { + action: string; +} + +type BootVersion = Identifiable; +type Packaging = Identifiable; +type JavaVersion = Identifiable; +type Language = Identifiable; + +export interface DependencyGroup extends Category, Nameable { +} + +export interface Dependency extends Identifiable { + description?: string; + versionRange?: string; +} diff --git a/src/model/ServiceManager.ts b/src/model/ServiceManager.ts index 71850d3..85e5bac 100644 --- a/src/model/ServiceManager.ts +++ b/src/model/ServiceManager.ts @@ -1,74 +1,67 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT license. -import * as _ from "lodash"; -import { IDependency, IStarters, ITopLevelAttribute } from "."; +import { IDependency } from "."; import { downloadFile } from "../Utils"; import { matchRange } from "../Utils/VersionHelper"; +import { DependencyGroup, Metadata } from "./Metadata"; -export class ServiceManager { - private static isCompatible(dep: IDependency, bootVersion: string): boolean { - if (bootVersion && dep && dep.versionRange) { - return matchRange(bootVersion, dep.versionRange); - } else { - return true; - } - } - - private serviceUrl: string; - private overview: { - dependencies: ITopLevelAttribute, - // tslint:disable-next-line:no-reserved-keywords - type: ITopLevelAttribute, - packaging: ITopLevelAttribute, - javaVersion: ITopLevelAttribute, - language: ITopLevelAttribute, - bootVersion: ITopLevelAttribute, - }; - private starters: { [bootVersion: string]: IStarters } = {}; +/** + * Prefer v2.2 and fallback to v2.1 + * See: https://github.com/microsoft/vscode-spring-initializr/issues/138 + */ +const METADATA_HEADERS = { Accept: "application/vnd.initializr.v2.2+json,application/vnd.initializr.v2.1+json;q=0.9" }; - constructor(serviceUrl: string) { - this.serviceUrl = serviceUrl; - } +class ServiceManager { + private metadataMap: Map = new Map(); - public async getStarters(bootVersion: string): Promise { - if (!this.starters[bootVersion]) { - const rawJSONString: string = await downloadFile(`${this.serviceUrl}dependencies?bootVersion=${bootVersion}`, true, { Accept: "application/vnd.initializr.v2.1+json" }); - this.starters[bootVersion] = JSON.parse(rawJSONString); + public async getBootVersions(serviceUrl: string): Promise { + const metadata = await this.ensureMetadata(serviceUrl); + if (!metadata) { + throw new Error("Failed to fetch metadata."); } - return _.cloneDeep(this.starters[bootVersion]); + + const defaultVersion: string = metadata.bootVersion.default; + const versions = metadata.bootVersion.values; + return versions.filter(x => x.id === defaultVersion).concat(versions.filter(x => x.id !== defaultVersion)); } - public async getBootVersions(): Promise { - if (!this.overview) { - await this.update(); + public async getAvailableDependencies(serviceUrl: string, bootVersion: string): Promise { + const metadata = await this.ensureMetadata(serviceUrl); + if (!metadata) { + throw new Error("Failed to fetch metadata."); } - if (!this.overview.bootVersion) { - return []; - } else { - return this.overview.bootVersion.values.filter(x => x.id === this.overview.bootVersion.default) - .concat(this.overview.bootVersion.values.filter(x => x.id !== this.overview.bootVersion.default)); + + const groups: DependencyGroup[] = metadata.dependencies.values; + const ret: IDependency[] = []; + for (const group of groups) { + const groupName: string = group.name; + const starters = group.values; + for (const starter of starters) { + if (!starter.versionRange || matchRange(bootVersion, starter.versionRange)) { + ret.push(Object.assign({ group: groupName }, starter)); + } + } } + return ret; } - public async getAvailableDependencies(bootVersion: string): Promise { - if (!this.overview) { - await this.update(); - } - if (!this.overview.dependencies) { - return []; - } else { - const ret: IDependency[] = []; - for (const grp of this.overview.dependencies.values) { - const group: string = grp.name; - ret.push(...grp.values.filter(dep => ServiceManager.isCompatible(dep, bootVersion)).map(dep => Object.assign({ group }, dep))); - } - return ret; + private async fetch(serviceUrl: string): Promise { + try { + const rawJSONString: string = await downloadFile(serviceUrl, true, METADATA_HEADERS); + const metadata = JSON.parse(rawJSONString); + this.metadataMap.set(serviceUrl, metadata); + } catch (error) { + console.error(error); } } - private async update(): Promise { - const rawJSONString: string = await downloadFile(this.serviceUrl, true, { Accept: "application/vnd.initializr.v2.1+json" }); - this.overview = JSON.parse(rawJSONString); + private async ensureMetadata(serviceUrl: string): Promise { + if (this.metadataMap.get(serviceUrl) === undefined) { + await this.fetch(serviceUrl); + } + return this.metadataMap.get(serviceUrl); } } + +export const serviceManager = new ServiceManager(); diff --git a/src/model/index.ts b/src/model/index.ts index f19fa08..b356ea9 100644 --- a/src/model/index.ts +++ b/src/model/index.ts @@ -6,4 +6,4 @@ export * from "./pomxml/BomNode"; export * from "./pomxml/DependencyNode"; export * from "./pomxml/PomXml"; export * from "./pomxml/RepositoryNode"; -export { ServiceManager } from "./ServiceManager"; +export { serviceManager } from "./ServiceManager";