Skip to content

Commit

Permalink
Create tool tsp-client (#6843)
Browse files Browse the repository at this point in the history
* create tool tsp-client

* [tsp-client] Refactor tsp-client (#6887)

* update options

* add additional fs helper methods

* update options and usage text

* refactor + update commands

* add additional directories support

* add function to compile from shell

* use additional dirs, call tsp compile

* return empty list for additional dirs

* add init option

* use service as project name

* improve directory naming

* allow raw github content url for init, include additional dirs in yaml

* rename file

* use cp with promises

* fix remove dir

* remove existssync

* clean up

* more clean up

* rename to syncAndGenerate

* remove then from fs

* remove then use

* remove var

* improve loop

* remove shell usage

* refactor url parser

* refactor to use compile method from typespec lib

* Rename function

* add more init functionality

* update log message

* add support for local spec repo

* add emitter options flag

* reorganize code

* support commit and repo update

* support config url update

* simplify emitter search in emitter-package.json

* update package.json

* prompt user for correct output dir

* improve error message

* clean up

* add initial local spec support

* fix author

* update chaining format

* fix resolveTspConfigUrl

* simplify tspconfig variable search

* fix getEmitterFromRepoConfig

* fs updates

* update package.json

---------

Co-authored-by: catalinaperalta <[email protected]>
  • Loading branch information
xirzec and catalinaperalta authored Oct 9, 2023
1 parent cbd469a commit 865b861
Show file tree
Hide file tree
Showing 18 changed files with 1,310 additions and 0 deletions.
5 changes: 5 additions & 0 deletions tools/tsp-client/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
node_modules
*.env
dist
types
temp
9 changes: 9 additions & 0 deletions tools/tsp-client/.prettierrc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"arrowParens": "always",
"bracketSpacing": true,
"endOfLine": "lf",
"printWidth": 100,
"semi": true,
"singleQuote": false,
"tabWidth": 2
}
46 changes: 46 additions & 0 deletions tools/tsp-client/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# tsp-client

A simple command line tool for generating TypeSpec clients.

### Usage
```
tsp-client [options] <outputDir>
```

## Options

### <outputDir> (required)

The only positional parameter. Specifies the directory to pass to the language emitter.

### --emitter, -e (required)

Specifies which language emitter to use. Current choices are "csharp", "java", "javascript", "python", "openapi".

Aliases are also available, such as cs, js, py, and ts.

### --mainFile, -m

Used when specifying a URL to a TSP file directly. Not required if using a `tsp-location.yaml`

### --debug, -d

Enables verbose debug logging to the console.

### --no-cleanup

Disables automatic cleanup of the temporary directory where the TSP is written and referenced npm modules are installed.

## Examples

Generating from a TSP file to a particular directory:

```
tsp-client -e openapi -m https://raw.githubusercontent.com/Azure/azure-rest-api-specs/main/specification/cognitiveservices/OpenAI.Inference/main.tsp ./temp
```

Generating in a directory that contains a `tsp-location.yaml`:

```
tsp-client sdk/openai/openai
```
2 changes: 2 additions & 0 deletions tools/tsp-client/cmd/tsp-client.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
#!/usr/bin/env node
await import("../dist/index.js");
55 changes: 55 additions & 0 deletions tools/tsp-client/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
{
"name": "@azure-tools/tsp-client",
"version": "0.0.1",
"private": "true",
"description": "A tool to generate Azure SDKs from TypeSpec",
"main": "dist/index.js",
"scripts": {
"build": "npm run clean && npm run build:tsc",
"build:tsc": "tsc",
"clean": "rimraf ./dist ./types",
"example": "npx ts-node src/index.ts -e openapi -m https://raw.githubusercontent.com/Azure/azure-rest-api-specs/main/specification/cognitiveservices/OpenAI.Inference/main.tsp ./temp",
"test": "mocha"
},
"author": "Microsoft Corporation",
"license": "MIT",
"type": "module",
"engines": {
"node": ">=18.0.0"
},
"bin": {
"tsp-client": "cmd/tsp-client.js"
},
"files": [
"dist",
"cmd/tsp-client.js"
],
"devDependencies": {
"@types/chai": "^4.3.5",
"@types/mocha": "^10.0.1",
"@types/node": "^20.4.8",
"@types/prompt-sync": "^4.2.1",
"chai": "^4.3.7",
"mocha": "^10.2.0",
"prettier": "^3.0.1",
"rimraf": "^5.0.1",
"ts-node": "^10.9.1",
"typescript": "^5.1.6"
},
"dependencies": {
"@azure/core-rest-pipeline": "^1.12.0",
"chalk": "^5.3.0",
"prompt-sync": "^4.2.0",
"yaml": "^2.3.1"
},
"peerDependencies": {
"@typespec/compiler": ">=0.48.1 <1.0.0"
},
"mocha": {
"extension": [
"ts"
],
"spec": "test/**/*.spec.ts",
"loader": "ts-node/esm"
}
}
79 changes: 79 additions & 0 deletions tools/tsp-client/src/fileTree.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
export function createFileTree(url: string): FileTree {
const rootUrl = url;
const fileMap = new Map<string, string>();

function longestCommonPrefix(a: string, b: string): string {
if (a === b) {
return a;
}
let lastCommonSlash = -1;
for (let i = 0; i < Math.min(a.length, b.length); i++) {
if (a[i] === b[i]) {
if (a[i] === "/") {
lastCommonSlash = i;
}
} else {
break;
}
}
if (lastCommonSlash === -1) {
throw new Error("no common prefix found");
}
return a.slice(0, lastCommonSlash + 1);
}

function findCommonRoot(): string {
let candidate = "";
for (const fileUrl of fileMap.keys()) {
const lastSlashIndex = fileUrl.lastIndexOf("/");
const dirUrl = fileUrl.slice(0, lastSlashIndex + 1);
if (!candidate) {
candidate = dirUrl;
} else {
candidate = longestCommonPrefix(candidate, dirUrl);
}
}
return candidate;
}

return {
addFile(url: string, contents: string): void {
if (fileMap.has(url)) {
throw new Error(`file already parsed: ${url}`);
}
fileMap.set(url, contents);
},
async createTree(): Promise<FileTreeResult> {
const outputFiles = new Map<string, string>();
// calculate the highest common root
const root = findCommonRoot();
let mainFilePath = "";
for (const [url, contents] of fileMap.entries()) {
const relativePath = url.slice(root.length);
outputFiles.set(relativePath, contents);
if (url === rootUrl) {
mainFilePath = relativePath;
}
}
if (!mainFilePath) {
throw new RangeError(
`Main file ${rootUrl} not added to FileTree. Did you forget to add it?`,
);
}
return {
mainFilePath,
files: outputFiles,
};
},
};
}

export interface FileTreeResult {
mainFilePath: string;
files: Map<string, string>;
}

export interface FileTree {
addFile(url: string, contents: string): void;
createTree(): Promise<FileTreeResult>;
}
92 changes: 92 additions & 0 deletions tools/tsp-client/src/fs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { mkdir, rm, writeFile, stat, readFile, access } from "node:fs/promises";
import { FileTreeResult } from "./fileTree.js";
import * as path from "node:path";
import { Logger } from "./log.js";
import { parse as parseYaml } from "yaml";

export async function ensureDirectory(path: string) {
await mkdir(path, { recursive: true });
}

export async function removeDirectory(path: string) {
await rm(path, { recursive: true, force: true });
}

export async function createTempDirectory(outputDir: string): Promise<string> {
const tempRoot = path.join(outputDir, "TempTypeSpecFiles");
await mkdir(tempRoot, { recursive: true });
Logger.debug(`Created temporary working directory ${tempRoot}`);
return tempRoot;
}

export async function writeFileTree(rootDir: string, files: FileTreeResult["files"]) {
for (const [relativeFilePath, contents] of files) {
const filePath = path.join(rootDir, relativeFilePath);
await ensureDirectory(path.dirname(filePath));
Logger.debug(`writing ${filePath}`);
await writeFile(filePath, contents);
}
}

export async function tryReadTspLocation(rootDir: string): Promise<string | undefined> {
try {
const yamlPath = path.resolve(rootDir, "tsp-location.yaml");
const fileStat = await stat(yamlPath);
if (fileStat.isFile()) {
const fileContents = await readFile(yamlPath, "utf8");
const locationYaml = parseYaml(fileContents);
const { directory, commit, repo } = locationYaml;
if (!directory || !commit || !repo) {
throw new Error("Invalid tsp-location.yaml");
}
// make GitHub URL
return `https://raw.githubusercontent.com/${repo}/${commit}/${directory}/`;
}
} catch (e) {
Logger.error(`Error reading tsp-location.yaml: ${e}`);
}
return undefined;
}

export async function readTspLocation(rootDir: string): Promise<[string, string, string, string[]]> {
try {
const yamlPath = path.resolve(rootDir, "tsp-location.yaml");
const fileStat = await stat(yamlPath);
if (fileStat.isFile()) {
const fileContents = await readFile(yamlPath, "utf8");
const locationYaml = parseYaml(fileContents);
let { directory, commit, repo, additionalDirectories } = locationYaml;
if (!directory || !commit || !repo) {
throw new Error("Invalid tsp-location.yaml");
}
Logger.info(`Additional directories: ${additionalDirectories}`)
if (!additionalDirectories) {
additionalDirectories = [];
}
return [ directory, commit, repo, additionalDirectories ];
}
throw new Error("Could not find tsp-location.yaml");
} catch (e) {
Logger.error(`Error reading tsp-location.yaml: ${e}`);
throw e;
}
}


export async function getEmitterFromRepoConfig(emitterPath: string): Promise<string> {
await access(emitterPath);
const data = await readFile(emitterPath, 'utf8');
const obj = JSON.parse(data);
if (!obj || !obj.dependencies) {
throw new Error("Invalid emitter-package.json");
}
const languages: string[] = ["@azure-tools/typespec-", "@typespec/openapi3"];
for (const lang of languages) {
const emitter = Object.keys(obj.dependencies).find((dep: string) => dep.startsWith(lang));
if (emitter) {
Logger.info(`Found emitter package ${emitter}`);
return emitter;
}
}
throw new Error("Could not find emitter package");
}
82 changes: 82 additions & 0 deletions tools/tsp-client/src/git.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { execSync, spawn } from "child_process";

export function getRepoRoot(): string {
return execSync('git rev-parse --show-toplevel').toString().trim();
}

export async function cloneRepo(rootUrl: string, cloneDir: string, repo: string): Promise<void> {
return new Promise((resolve, reject) => {
const git = spawn("git", ["clone", "--no-checkout", "--filter=tree:0", repo, cloneDir], {
cwd: rootUrl,
stdio: "inherit",
});
git.once("exit", (code) => {
if (code === 0) {
resolve();
} else {
reject(new Error(`git clone failed exited with code ${code}`));
}
});
git.once("error", (err) => {
reject(new Error(`git clone failed with error: ${err}`));
});
});
}


export async function sparseCheckout(cloneDir: string): Promise<void> {
return new Promise((resolve, reject) => {
const git = spawn("git", ["sparse-checkout", "init"], {
cwd: cloneDir,
stdio: "inherit",
});
git.once("exit", (code) => {
if (code === 0) {
resolve();
} else {
reject(new Error(`git sparse-checkout failed exited with code ${code}`));
}
});
git.once("error", (err) => {
reject(new Error(`git sparse-checkout failed with error: ${err}`));
});
});
}

export async function addSpecFiles(cloneDir: string, subDir: string): Promise<void> {
return new Promise((resolve, reject) => {
const git = spawn("git", ["sparse-checkout", "add", subDir], {
cwd: cloneDir,
stdio: "inherit",
});
git.once("exit", (code) => {
if (code === 0) {
resolve();
} else {
reject(new Error(`git sparse-checkout add failed exited with code ${code}`));
}
});
git.once("error", (err) => {
reject(new Error(`git sparse-checkout add failed with error: ${err}`));
});
});
}

export async function checkoutCommit(cloneDir: string, commit: string): Promise<void> {
return new Promise((resolve, reject) => {
const git = spawn("git", ["checkout", commit], {
cwd: cloneDir,
stdio: "inherit",
});
git.once("exit", (code) => {
if (code === 0) {
resolve();
} else {
reject(new Error(`git checkout failed exited with code ${code}`));
}
});
git.once("error", (err) => {
reject(new Error(`git checkout failed with error: ${err}`));
});
});
}
Loading

0 comments on commit 865b861

Please sign in to comment.