diff --git a/.github/workflows/draft-new-release.yml b/.github/workflows/draft-new-release.yml index 7d9289b2..632a8571 100644 --- a/.github/workflows/draft-new-release.yml +++ b/.github/workflows/draft-new-release.yml @@ -44,9 +44,10 @@ jobs: source_branch_name=${GITHUB_REF##*/} release_type=release grep -q "hotfix/" <<< "${GITHUB_REF}" && release_type=hotfix-release - git fetch origin master + git remote show origin + git fetch origin master --depth=1 git fetch --tags origin - git merge origin/master + echo "Merging master into $source_branch_name" current_version=$(jq -r .version package.json) yarn config set version-git-tag false @@ -93,4 +94,4 @@ jobs: github_token: ${{ secrets.PAT }} pr_title: 'chore(release): pulling ${{ steps.create-release.outputs.branch_name }} into master' pr_body: ':crown: *An automated PR*' - pr_reviewer: 'bardisg,MoumitaM,saikumarrs' + pr_reviewer: 'ssbeefeater,akashrpo,MoumitaM,1abhishekpandey,saikumarrs' diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 00000000..6221c72f --- /dev/null +++ b/.prettierignore @@ -0,0 +1,4 @@ +dist +node_modules +.git +CHANGELOG.md \ No newline at end of file diff --git a/README.md b/README.md index 1ec0a0c2..8e993228 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,16 @@ # RudderTyper -**RudderTyper** is a tool for generating strongly-typed [**RudderStack**](https://rudderstack.com/) analytics libraries based on your pre-defined +**RudderTyper** is a tool for generating strongly-typed [**RudderStack**](https://rudderstack.com/) analytics libraries based on your pre-defined tracking plan spec.

+

RudderTyper GIF Example


-## Features +## Features - **Strongly Typed Clients**: Generates strongly-typed [RudderStack](http://rudderstack.com) clients that provide compile-time errors, along with intellisense for events and property names, types and descriptions. @@ -18,7 +19,7 @@ tracking plan spec. - **Cross-Language Support**: Supports native clients for [**Javascript**](https://docs.rudderstack.com/stream-sources/rudderstack-sdk-integration-guides/rudderstack-javascript-sdk), [**Node.js**](https://docs.rudderstack.com/stream-sources/rudderstack-sdk-integration-guides/rudderstack-node-sdk), [**Android**](https://docs.rudderstack.com/stream-sources/rudderstack-sdk-integration-guides/rudderstack-android-sdk) and [**iOS**](https://docs.rudderstack.com/stream-sources/rudderstack-sdk-integration-guides/rudderstack-ios-sdk). - **RudderStack Tracking Plans**: Built-in support to sync your `ruddertyper` clients with your centralized RudderStack tracking plans. -
+
## Get Started @@ -28,7 +29,6 @@ To fire up a quickstart wizard to create a `ruddertyper.yml` and generate your f $ npx rudder-typer init | initialize | quickstart ``` - ## Other Commands ### Update @@ -42,7 +42,7 @@ This command syncs `plan.json` with RudderStack to pull the latest changes in yo ### Build ```sh -$ npx rudder-typer build | b | d | dev | development +$ npx rudder-typer build | b | d | dev | development ``` This command generates a development client from `plan.json`. @@ -50,7 +50,7 @@ This command generates a development client from `plan.json`. ### Production ```sh -$ npx rudder-typer prod | p | production +$ npx rudder-typer prod | p | production ``` This command generates a production client from `plan.json`. @@ -58,15 +58,17 @@ This command generates a production client from `plan.json`. ### Token ```sh -$ npx rudder-typer token | tokens | t +$ npx rudder-typer token | tokens | t ``` + This command prints the local RudderStack API token configuration. ### Version ```sh -$ npx rudder-typer version +$ npx rudder-typer version ``` + This command prints the RudderTyper CLI version. ### Help @@ -77,22 +79,20 @@ $ npx rudder-typer help This command prints the help message describing different commands available with RudderTyper. - ## CLI Arguments -| Argument | Type | Description | -| :--- | :--- | :--- | -| `config` | `string` | An optional path to a `ruddertyper.yml` (or a directory with `ruddertyper.yml`). | -| `debug` | `boolean` | An optional (hidden) flag for enabling Ink debug mode. | -| `version` | `boolean` | Standard `--version` flag to print the version of this CLI. | -| `v` | `boolean` | Standard `-v` flag to print the version of this CLI. | -| `help` | `boolean` | Standard `--help` flag to print help on a command. | -| `h` | `boolean` | Standard `-h` flag to print help on a command. | - +| Argument | Type | Description | +| :-------- | :-------- | :------------------------------------------------------------------------------- | +| `config` | `string` | An optional path to a `ruddertyper.yml` (or a directory with `ruddertyper.yml`). | +| `debug` | `boolean` | An optional (hidden) flag for enabling Ink debug mode. | +| `version` | `boolean` | Standard `--version` flag to print the version of this CLI. | +| `v` | `boolean` | Standard `-v` flag to print the version of this CLI. | +| `help` | `boolean` | Standard `--help` flag to print help on a command. | +| `h` | `boolean` | Standard `-h` flag to print help on a command. | ## Configuration Reference -RudderTyper stores its configuration in a `ruddertyper.yml` file in the root of your repository. +RudderTyper stores its configuration in a `ruddertyper.yml` file in the root of your repository. A sample configuration looks like the following: @@ -101,9 +101,9 @@ A sample configuration looks like the following: # Just run `npx rudder-typer` to re-generate a client with the latest versions of these events. scripts: - # You can supply a RudderStack API token using a `script.token` command. The output of `script.token` command should be a valid RudderStack API token. + # You can supply a RudderStack API token using a `script.token` command. The output of `script.token` command should be a valid RudderStack API token. token: source .env; echo $RUDDERTYPER_TOKEN - # You can supply email address linked to your workspace using a `script.email` command.The output of `script.email` command should be an email address registered with your workspace. + # You can supply email address linked to your workspace using a `script.email` command.The output of `script.email` command should be an email address registered with your workspace. email: source .env: echo $EMAIL # You can format any of RudderTyper's auto-generated files using a `script.after` command. # See `Formatting Generated Files` below. @@ -129,7 +129,7 @@ trackingPlans: - id: rs_QhWHOgp7xg8wkYxilH3scd2uRID workspaceSlug: rudderstack-demo path: ./analytics - ``` +``` ## How to integrate RudderTyper-generated client with your app? @@ -137,9 +137,9 @@ This section includes steps to integrate your RudderTyper-generated client with ### RudderStack Android SDK -* Import all the files in the client generated by RudderTyper as a package in your project. +- Import all the files in the client generated by RudderTyper as a package in your project. -* Then, you can directly make the calls using the RudderTyper client as shown below: +- Then, you can directly make the calls using the RudderTyper client as shown below: ```java // Import your auto-generated RudderTyper client: @@ -156,11 +156,11 @@ import com.rudderstack.generated.* ### RudderStack iOS SDK -* Import your RudderTyper client into your project using XCode. +- Import your RudderTyper client into your project using XCode. **Note**: If you place your generated files into a folder in your project, import the project as a group not a folder reference. -* Then, you can directly make the calls using the RudderTyper client as shown: +- Then, you can directly make the calls using the RudderTyper client as shown: ```obj-c // Import your auto-generated RudderTyper client: @@ -172,90 +172,100 @@ import com.rudderstack.generated.* ### RudderStack JavaScript SDK -* Import the RudderTyper-generated client using `require()` and make the calls if your framework supports them. Otherwise, you can use [**Browserify**](https://browserify.org/) to generate a bundle that supports your implementation. The implementation for each of the alternatives mentioned above will be as shown: - +- Import the RudderTyper-generated client using `require()` and make the calls if your framework supports them. Otherwise, you can use [**Browserify**](https://browserify.org/) to generate a bundle that supports your implementation. The implementation for each of the alternatives mentioned above will be as shown: #### Using the `require()`method ```javascript // Import RudderStack JS SDK and initialize it -const rudderanalytics = require("rudder-sdk-js") -rudderanalytics.load(YOUR_WRITE_KEY, DATA_PLANE_URL) +const rudderanalytics = require('rudder-sdk-js'); +rudderanalytics.load(YOUR_WRITE_KEY, DATA_PLANE_URL); // Import your auto-generated RudderTyper client: -const rudderTyper = require('./rudderTyperClient') +const rudderTyper = require('./rudderTyperClient'); // Pass in your rudder-sdk-js instance to RudderTyper client rudderTyper.setRudderTyperOptions({ - analytics: rudderanalytics + analytics: rudderanalytics, }); // Issue your first RudderTyper track call! rudderTyper.orderCompleted({ orderID: 'ck-f306fe0e-cc21-445a-9caa-08245a9aa52c', - total: 39.99 -}) + total: 39.99, +}); ``` #### Using `browserify` -* Execute the following command to generate a bundle from the RudderTyper client: +- Execute the following command to generate a bundle from the RudderTyper client: ```sh browserify rudderTyperClient.js --standalone rudderTyper > rudderTyperBundle.js ``` -* Now you can make calls from your `html` file as shown: +- Now you can make calls from your `html` file as shown: ```html - - - + + + Document ``` ### RudderStack Node.js SDK: -* Import the the RudderTyper-generated client and start making calls using RudderTyper as shown: +- Import the the RudderTyper-generated client and start making calls using RudderTyper as shown: ```javascript // Import Rudder Node SDK and intialize it -const Analytics = require("@rudderstack/rudder-sdk-node"); +const Analytics = require('@rudderstack/rudder-sdk-node'); const client = new Analytics(WRITE_KEY, DATA_PLANE_URL / v1 / batch); -const ruddertyper = require("./rudderTyperClient"); +const ruddertyper = require('./rudderTyperClient'); // Pass in your rudder-sdk-node instance to RudderTyper. ruddertyper.setRudderTyperOptions({ - analytics: client + analytics: client, }); // Issue your first RudderTyper track call! ruddertyper.orderCompleted({ orderID: 'ck-f306fe0e-cc21-445a-9caa-08245a9aa52c', - total: 39.99 -}) + total: 39.99, +}); ``` ## Contribute @@ -265,4 +275,4 @@ ruddertyper.orderCompleted({ ## Contact Us -For queries on any of the sections in this guide, start a conversation on our [**Slack**](https://resources.rudderstack.com/join-rudderstack-slack) channel. +For queries on any of the sections in this guide, start a conversation on our [**Slack**](https://resources.rudderstack.com/join-rudderstack-slack) channel. diff --git a/package.json b/package.json index 05f1c5ae..8e82076e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "rudder-typer", - "version": "1.0.0-beta.6", + "version": "1.0.0-beta.7", "description": "A compiler for generating strongly typed analytics clients via RudderStack", "repository": "ssh://git@github.com/rudderlabs/rudder-typer.git", "homepage": "https://github.com/rudderlabs/rudder-typer", @@ -18,6 +18,8 @@ "e2e": "make e2e", "update": "make update", "lint": "eslint './src/**/*.{ts,tsx}'", + "format": "prettier --write ./src/**/*.{ts,tsx}", + "format:check": "prettier --check ./src/**/*.{ts,tsx}", "release": "yarn run build && np --any-branch --contents dist", "release:pre": "yarn run build && np prerelease --any-branch --tag next --no-release-draft --contents dist", "release:ci": "yarn publish dist --tag next --new-version $NPM_PACKAGE_NEW_VERSION" @@ -74,7 +76,7 @@ "lodash": "4.17.20", "node-machine-id": "^1.1.12", "object-assign": "^4.1.1", - "prettier": "^1.17.0", + "prettier": "^1.19.1", "react": "^16.9.0", "semver": "^6.3.1", "sort-keys": "^3.0.0", diff --git a/src/@types/ink-link.d.ts b/src/@types/ink-link.d.ts index 38edeecc..79348ffa 100644 --- a/src/@types/ink-link.d.ts +++ b/src/@types/ink-link.d.ts @@ -1,9 +1,9 @@ declare module 'ink-link' { - interface LinkProps { - url: string - } - /** https://github.com/sindresorhus/ink-link */ - const Link: React.FC + interface LinkProps { + url: string; + } + /** https://github.com/sindresorhus/ink-link */ + const Link: React.FC; - export default Link + export default Link; } diff --git a/src/@types/ink-spinner.d.ts b/src/@types/ink-spinner.d.ts index 292a4279..f409256a 100644 --- a/src/@types/ink-spinner.d.ts +++ b/src/@types/ink-spinner.d.ts @@ -1,10 +1,10 @@ declare module 'ink-spinner' { - interface SpinnerProps { - /** See: https://github.com/sindresorhus/cli-spinners/blob/master/spinners.json */ - type: string - } - /** https://github.com/vadimdemedes/ink-spinner */ - const Spinner: React.FC + interface SpinnerProps { + /** See: https://github.com/sindresorhus/cli-spinners/blob/master/spinners.json */ + type: string; + } + /** https://github.com/vadimdemedes/ink-spinner */ + const Spinner: React.FC; - export default Spinner + export default Spinner; } diff --git a/src/@types/sort-keys.d.ts b/src/@types/sort-keys.d.ts index e8b7f166..5c3124c2 100644 --- a/src/@types/sort-keys.d.ts +++ b/src/@types/sort-keys.d.ts @@ -1 +1 @@ -declare module 'sort-keys' +declare module 'sort-keys'; diff --git a/src/@types/types.d.ts b/src/@types/types.d.ts index f4efd237..09c33d05 100644 --- a/src/@types/types.d.ts +++ b/src/@types/types.d.ts @@ -5,5 +5,5 @@ */ /* eslint-disable @typescript-eslint/no-explicit-any */ type AsyncReturnType any> = T extends (...args: any) => Promise - ? R - : any + ? R + : any; diff --git a/src/cli/api/api.ts b/src/cli/api/api.ts index bc7aebe3..40238309 100644 --- a/src/cli/api/api.ts +++ b/src/cli/api/api.ts @@ -1,175 +1,263 @@ -import got from 'got' -import { JSONSchema7 } from 'json-schema' -import { version } from '../../../package.json' -import { wrapError, isWrappedError } from '../commands/error' -import { sanitizeTrackingPlan } from './trackingplans' -import { set } from 'lodash' -import { APIError } from '../types' +import got from 'got'; +import { JSONSchema7 } from 'json-schema'; +import { version } from '../../../package.json'; +import { wrapError, isWrappedError } from '../commands/error'; +import { sanitizeTrackingPlan } from './trackingplans'; +import { set } from 'lodash'; +import { APIError } from '../types'; export namespace RudderAPI { - export type GetTrackingPlanResponse = TrackingPlan - - export type ListTrackingPlansResponse = { - tracking_plans: TrackingPlan[] - } - - export type TrackingPlan = { - name: string - display_name: string - version: string - rules: { - events: RuleMetadata[] - global: RuleMetadata - identify_traits: RuleMetadata - group_traits: RuleMetadata - } - create_time: Date - update_time: Date - } - - export type RuleMetadata = { - name: string - description?: string - rules: JSONSchema7 - version: number - } - - export type ListWorkspacesResponse = Workspace - - export type Workspace = { - name: string - display_name: string - id: string - create_time: Date - } + export type GetTrackingPlanResponse = TrackingPlan; + + export type GetTrackingPlanEventsResponse = TrackingPlanEvents; + + export type GetTrackingPlanEventsRulesResponse = { + name: string; + description?: string; + rules: JSONSchema7; + }; + + export type ListTrackingPlansResponse = { + tracking_plans: TrackingPlan[]; + }; + + export type ListTrackingPlansResponseV2 = { + trackingPlans: TrackingPlan[]; + }; + + export type TrackingPlan = { + name: string; + display_name: string; + version: string; + id: string; + rules: { + events: RuleMetadata[]; + global?: RuleMetadata; + identify_traits?: RuleMetadata; + group_traits?: RuleMetadata; + }; + create_time: Date; + update_time: Date; + createdAt: Date; + updatedAt: Date; + creationType: string; + workspaceId: string; + }; + + export type TrackingPlanEvents = { + data: { + id: string; + name: string; + description: string; + eventType: string; + categoryId: string; + workspaceId: string; + createdBy: string; + updatedBy: string; + createdAt: Date; + updatedAt: Date; + identitySection: string; + additionalProperties: boolean; + }[]; + }; + + export type RuleMetadata = { + name: string; + description?: string; + rules: JSONSchema7; + }; + + export type ListWorkspacesResponse = Workspace; + + export type Workspace = { + name: string; + id: string; + createdAt: Date; + }; } export async function fetchTrackingPlan(options: { - workspaceSlug: string - id: string - token: string - email: string + workspaceSlug: string; + id: string; + token: string; + email: string; + APIVersion: string; }): Promise { - const url = `trackingplans/${options.id}` - const response = await apiGet( - url, - options.token, - options.email - ) + const url = + options.APIVersion === 'v1' ? `trackingplans/${options.id}` : `tracking-plans/${options.id}`; + const response = await apiGet( + url, + options.token, + options.email, + ); - response.create_time = new Date(response.create_time) - response.update_time = new Date(response.update_time) + if (options.APIVersion === 'v2') { + response.createdAt = new Date(response.createdAt); + response.updatedAt = new Date(response.updatedAt); + } else { + response.create_time = new Date(response.create_time); + response.update_time = new Date(response.update_time); + } - return sanitizeTrackingPlan(response) + if (options.APIVersion === 'v2') { + const url = `tracking-plans/${options.id}/events`; + const eventsResponse = await apiGet( + url, + options.token, + options.email, + ); + if (eventsResponse) { + const eventsRulesResponsePromise = eventsResponse.data + .filter(ev => ev.eventType === 'track') + .map(async ev => { + const url = `tracking-plans/${options.id}/events/${ev.id}`; + const eventsRulesResponse = await apiGet( + url, + options.token, + options.email, + ); + return { + name: eventsRulesResponse.name, + description: eventsRulesResponse.description, + rules: eventsRulesResponse.rules, + }; + }); + const eventsRulesResponse: RudderAPI.RuleMetadata[] = await Promise.all( + eventsRulesResponsePromise, + ); + response.rules = { + events: eventsRulesResponse, + }; + } + } + return sanitizeTrackingPlan(response); } // fetchTrackingPlans fetches all Tracking Plans accessible by a given API token // within a specified workspace. export async function fetchTrackingPlans(options: { - token: string - email: string + token: string; + email: string; }): Promise { - const url = 'trackingplans' - const response = await apiGet( - url, - options.token, - options.email - ) - return response.tracking_plans.map(tp => ({ - ...tp, - create_time: new Date(tp.create_time), - update_time: new Date(tp.update_time), - })) + const response = await apiGet( + 'trackingplans', + options.token, + options.email, + ); + response.tracking_plans.map(tp => ({ + ...tp, + createdAt: new Date(tp.create_time), + updatedAt: new Date(tp.update_time), + })); + + const responseV2 = await apiGet( + 'tracking-plans', + options.token, + options.email, + ); + responseV2.trackingPlans.map(tp => ({ + ...tp, + createdAt: new Date(tp.createdAt), + updatedAt: new Date(tp.updatedAt), + })); + + return response.tracking_plans.concat(responseV2.trackingPlans); } // fetchWorkspace lists the workspace found with a given Rudder API token. export async function fetchWorkspace(options: { - token: string - email: string + token: string; + email: string; }): Promise { - const resp = await apiGet( - 'workspace', - options.token, - options.email - ) - return { - ...resp, - create_time: new Date(resp.create_time), - } + const resp = await apiGet( + 'workspace', + options.token, + options.email, + ); + return { + ...resp, + createdAt: new Date(resp.createdAt), + }; } // validateToken returns true if a token is a valid Rudder API token. // Note: results are cached in-memory since it is commonly called multiple times // for the same token (f.e. in `config/`). type TokenValidationResult = { - isValid: boolean - workspace?: RudderAPI.Workspace -} -const tokenValidationCache: Record = {} + isValid: boolean; + workspace?: RudderAPI.Workspace; +}; +const tokenValidationCache: Record = {}; export async function validateToken( - token: string | undefined, - email: string | undefined + token: string | undefined, + email: string | undefined, ): Promise { - if (!token || !email) { - return { isValid: false } - } - - // If we don't have a cached result, query the API to find out if this is a valid token. - - if (!tokenValidationCache[token]) { - const result: TokenValidationResult = { isValid: false } - try { - const workspace = await fetchWorkspace({ token, email }) - result.isValid = workspace ? true : false - result.workspace = workspace ? workspace : undefined - } catch (error) { - // Check if this was a 403 error, which means the token is invalid. - // Otherwise, surface the error becuase something else went wrong. - if (!isWrappedError(error) || !error.description.toLowerCase().includes('denied')) { - throw error - } - } - tokenValidationCache[token] = result - } - - return tokenValidationCache[token] + if (!token || !email) { + return { isValid: false }; + } + + // If we don't have a cached result, query the API to find out if this is a valid token. + + if (!tokenValidationCache[token]) { + const result: TokenValidationResult = { isValid: false }; + try { + const workspace = await fetchWorkspace({ token, email }); + result.isValid = workspace ? true : false; + result.workspace = workspace ? workspace : undefined; + } catch (error) { + // Check if this was a 403 error, which means the token is invalid. + // Otherwise, surface the error becuase something else went wrong. + if (!isWrappedError(error) || !error.description.toLowerCase().includes('denied')) { + throw error; + } + } + tokenValidationCache[token] = result; + } + + return tokenValidationCache[token]; } async function apiGet(url: string, token: string, email: string): Promise { - const resp = got(url, { - baseUrl: - url === 'workspace' ? 'https://api.rudderstack.com/v1' : 'https://api.rudderstack.com/v1/dg', - headers: { - 'User-Agent': `RudderTyper: ${version})`, - Authorization: `Basic ${Buffer.from(email + ':' + token).toString('base64')}`, - }, - json: true, - timeout: 10000, // ms - }) - - try { - const { body } = await resp - return body - } catch (error) { - const err = error as APIError - // Don't include the user's authorization token. Overwrite the header value from this error. - const tokenHeader = `Bearer ${token.trim().substring(0, 10)}... (token redacted)` - error = set(err, 'gotOptions.headers.authorization', tokenHeader) - - if (err.statusCode === 401 || err.statusCode === 403) { - throw wrapError( - 'Permission denied by Rudder API', - err, - `Failed while querying the ${url} endpoint`, - "Verify you are using the right API token by running 'npx rudder-typer tokens'" - ) - } else if (err.code === 'ETIMEDOUT') { - throw wrapError( - 'Rudder API request timed out', - err, - `Failed while querying the ${url} endpoint` - ) - } - throw error - } + const resp = got(url, { + baseUrl: + url === 'workspace' + ? 'https://api.rudderstack.com/v1' + : url.includes('trackingplans') + ? 'https://api.rudderstack.com/v1/dg' + : 'https://api.rudderstack.com/v2/catalog', + headers: { + authorization: + url === 'workspace' || url.includes('trackingplans') + ? `Basic ${Buffer.from(email + ':' + token).toString('base64')}` + : 'Bearer ' + token, + }, + json: true, + timeout: 10000, // ms + }); + + try { + const { body } = await resp; + return body; + } catch (error) { + const err = error as APIError; + // Don't include the user's authorization token. Overwrite the header value from this error. + const tokenHeader = `Bearer ${token.trim().substring(0, 10)}... (token redacted)`; + error = set(err, 'gotOptions.headers.authorization', tokenHeader); + + if (err.statusCode === 401 || err.statusCode === 403) { + throw wrapError( + 'Permission denied by Rudder API', + err, + `Failed while querying the ${url} endpoint`, + "Verify you are using the right API token by running 'npx rudder-typer tokens'", + ); + } else if (err.code === 'ETIMEDOUT') { + throw wrapError( + 'Rudder API request timed out', + err, + `Failed while querying the ${url} endpoint`, + ); + } + throw error; + } } diff --git a/src/cli/api/index.ts b/src/cli/api/index.ts index 1270b2ad..607f1187 100644 --- a/src/cli/api/index.ts +++ b/src/cli/api/index.ts @@ -1,10 +1,10 @@ -export { RudderAPI, validateToken, fetchTrackingPlan, fetchTrackingPlans } from './api' +export { RudderAPI, validateToken, fetchTrackingPlan, fetchTrackingPlans } from './api'; export { - loadTrackingPlan, - writeTrackingPlan, - TRACKING_PLAN_FILENAME, - computeDelta, - toTrackingPlanURL, - parseTrackingPlanName, - TrackingPlanDeltas, -} from './trackingplans' + loadTrackingPlan, + writeTrackingPlan, + TRACKING_PLAN_FILENAME, + computeDelta, + toTrackingPlanURL, + parseTrackingPlanName, + TrackingPlanDeltas, +} from './trackingplans'; diff --git a/src/cli/api/trackingplans.ts b/src/cli/api/trackingplans.ts index 6eca7eab..0aef4458 100644 --- a/src/cli/api/trackingplans.ts +++ b/src/cli/api/trackingplans.ts @@ -1,135 +1,151 @@ -import { RudderAPI } from './api' -import { TrackingPlanConfig, resolveRelativePath, verifyDirectoryExists } from '../config' -import sortKeys from 'sort-keys' -import * as fs from 'fs' -import { promisify } from 'util' -import { flow } from 'lodash' -import stringify from 'json-stable-stringify' +import { RudderAPI } from './api'; +import { TrackingPlanConfig, resolveRelativePath, verifyDirectoryExists } from '../config'; +import sortKeys from 'sort-keys'; +import * as fs from 'fs'; +import { promisify } from 'util'; +import { flow, pickBy } from 'lodash'; +import stringify from 'json-stable-stringify'; -const writeFile = promisify(fs.writeFile) -const readFile = promisify(fs.readFile) +const writeFile = promisify(fs.writeFile); +const readFile = promisify(fs.readFile); -export const TRACKING_PLAN_FILENAME = 'plan.json' +export const TRACKING_PLAN_FILENAME = 'plan.json'; export async function loadTrackingPlan( - configPath: string | undefined, - config: TrackingPlanConfig + configPath: string | undefined, + config: TrackingPlanConfig, ): Promise { - const path = resolveRelativePath(configPath, config.path, TRACKING_PLAN_FILENAME) - - // Load the Tracking Plan from the local cache. - try { - const plan = JSON.parse( - await readFile(path, { - encoding: 'utf-8', - }) - ) as RudderAPI.TrackingPlan - - return await sanitizeTrackingPlan(plan) - } catch { - // We failed to read the Tracking Plan, possibly because no plan.json exists. - return undefined - } + const path = resolveRelativePath(configPath, config.path, TRACKING_PLAN_FILENAME); + + // Load the Tracking Plan from the local cache. + try { + const plan = JSON.parse( + await readFile(path, { + encoding: 'utf-8', + }), + ) as RudderAPI.TrackingPlan; + + return await sanitizeTrackingPlan(plan); + } catch { + // We failed to read the Tracking Plan, possibly because no plan.json exists. + return undefined; + } } export async function writeTrackingPlan( - configPath: string | undefined, - plan: RudderAPI.TrackingPlan, - config: TrackingPlanConfig + configPath: string | undefined, + plan: RudderAPI.TrackingPlan, + config: TrackingPlanConfig, ): Promise { - const path = resolveRelativePath(configPath, config.path, TRACKING_PLAN_FILENAME) - await verifyDirectoryExists(path, 'file') - - // Perform some pre-processing on the Tracking Plan before writing it. - const planJSON = flow( - // Enforce a deterministic ordering to reduce verson control deltas. - plan => sanitizeTrackingPlan(plan), - plan => stringify(plan, { space: '\t' }) - )(plan) - - await writeFile(path, planJSON, { - encoding: 'utf-8', - }) + const path = resolveRelativePath(configPath, config.path, TRACKING_PLAN_FILENAME); + await verifyDirectoryExists(path, 'file'); + + // Perform some pre-processing on the Tracking Plan before writing it. + const planJSON = flow( + // Enforce a deterministic ordering to reduce verson control deltas. + plan => sanitizeTrackingPlan(plan), + plan => stringify(plan, { space: '\t' }), + )(plan); + + await writeFile(path, planJSON, { + encoding: 'utf-8', + }); } export function sanitizeTrackingPlan(plan: RudderAPI.TrackingPlan): RudderAPI.TrackingPlan { - // TODO: on JSON Schema Draft-04, required fields must have at least one element. - // Therefore, we strip `required: []` from your rules so this error isn't surfaced. - return sortKeys(plan, { deep: true }) + // TODO: on JSON Schema Draft-04, required fields must have at least one element. + // Therefore, we strip `required: []` from your rules so this error isn't surfaced. + const cleanupPlan = pickBy(plan, v => v !== null); + return sortKeys(cleanupPlan, { deep: true }); } export type TrackingPlanDeltas = { - added: number - modified: number - removed: number -} + added: number; + modified: number; + removed: number; +}; export function computeDelta( - prev: RudderAPI.TrackingPlan | undefined, - next: RudderAPI.TrackingPlan + prev: RudderAPI.TrackingPlan | undefined, + next: RudderAPI.TrackingPlan, ): TrackingPlanDeltas { - const deltas: TrackingPlanDeltas = { - added: 0, - modified: 0, - removed: 0, - } - - // Since we only use track calls in ruddertyper, we only changes to track calls. - const nextByName: Record = {} - for (const rule of next.rules.events) { - nextByName[rule.name] = rule - } - const prevByName: Record = {} - if (!!prev) { - for (const rule of prev.rules.events) { - prevByName[rule.name] = rule - } - } - - for (const rule of next.rules.events) { - const prevRule = prevByName[rule.name] - if (!prevRule) { - deltas.added++ - } else { - if (JSON.stringify(rule) !== JSON.stringify(prevRule)) { - deltas.modified++ - } - } - } - if (!!prev) { - for (const rule of prev.rules.events) { - if (!nextByName[rule.name]) { - deltas.removed++ - } - } - } - - return deltas + const deltas: TrackingPlanDeltas = { + added: 0, + modified: 0, + removed: 0, + }; + + // Since we only use track calls in ruddertyper, we only changes to track calls. + const nextByName: Record = {}; + for (const rule of next.rules.events) { + nextByName[rule.name] = rule; + } + const prevByName: Record = {}; + if (!!prev) { + for (const rule of prev.rules.events) { + prevByName[rule.name] = rule; + } + } + + for (const rule of next.rules.events) { + const prevRule = prevByName[rule.name]; + if (!prevRule) { + deltas.added++; + } else { + if (JSON.stringify(rule) !== JSON.stringify(prevRule)) { + deltas.modified++; + } + } + } + if (!!prev) { + for (const rule of prev.rules.events) { + if (!nextByName[rule.name]) { + deltas.removed++; + } + } + } + + return deltas; } -export function parseTrackingPlanName(name: string): { id: string; workspaceSlug: string } { - const parts = name.split('/') +export function parseTrackingPlanName( + name: string, +): { id: string; workspaceSlug: string; APIVersion: string } { + const parts = name.split('/'); + + // Sane fallback: + if (parts.length !== 4 || (parts[0] !== 'workspaces' && parts[2] !== 'tracking-plans')) { + throw new Error(`Unable to parse Tracking Plan name: ${name}`); + } - // Sane fallback: - if (parts.length !== 4 || (parts[0] !== 'workspaces' && parts[2] !== 'tracking-plans')) { - throw new Error(`Unable to parse Tracking Plan name: ${name}`) - } + const workspaceSlug = parts[1]; + const id = parts[3]; - const workspaceSlug = parts[1] - const id = parts[3] + return { + id, + workspaceSlug, + APIVersion: 'v1', + }; +} - return { - id, - workspaceSlug, - } +export function toTrackingPlanURL(trackingPlan: RudderAPI.TrackingPlan): string { + if (!trackingPlan.creationType) { + const { id } = parseTrackingPlanName(trackingPlan.name); + return `https://api.rudderstack.com/trackingplans/${id}`; + } + return `https://api.rudderstack.com/tracking-plans/${trackingPlan.id}`; } -export function toTrackingPlanURL(name: string): string { - const { id } = parseTrackingPlanName(name) - return `https://api.rudderstack.com/trackingplans/${id}` +export function toTrackingPlanId(trackingPlan: RudderAPI.TrackingPlan): string { + if (!trackingPlan.creationType) { + const { id } = parseTrackingPlanName(trackingPlan.name); + return id; + } + return trackingPlan.id; } -export function toTrackingPlanId(name: string): string { - const { id } = parseTrackingPlanName(name) - return id +export function getTrackingPlanName( + trackingPlan: Pick, +): string { + return !trackingPlan.creationType ? trackingPlan.display_name : trackingPlan.name; } diff --git a/src/cli/commands/build.tsx b/src/cli/commands/build.tsx index 6f64179c..3bb8979b 100644 --- a/src/cli/commands/build.tsx +++ b/src/cli/commands/build.tsx @@ -1,527 +1,524 @@ -import React, { useState, useEffect, useContext } from 'react' -import { Box, Text, Color, useApp } from 'ink' -import Link from 'ink-link' -import Spinner from 'ink-spinner' +import React, { useState, useEffect, useContext } from 'react'; +import { Box, Text, Color, useApp } from 'ink'; +import Link from 'ink-link'; +import Spinner from 'ink-spinner'; import { - getToken, - resolveRelativePath, - Config, - verifyDirectoryExists, - runScript, - Scripts, -} from '../config' -import { JSONSchema7 } from 'json-schema' -import * as fs from 'fs' -import { promisify } from 'util' + getToken, + resolveRelativePath, + Config, + verifyDirectoryExists, + runScript, + Scripts, +} from '../config'; +import { JSONSchema7 } from 'json-schema'; +import * as fs from 'fs'; +import { promisify } from 'util'; import { - fetchTrackingPlan, - loadTrackingPlan, - writeTrackingPlan, - TrackingPlanDeltas, - computeDelta, - RudderAPI, - toTrackingPlanURL, -} from '../api' -import { gen, RawTrackingPlan } from '../../generators/gen' -import { RUDDER_AUTOGENERATED_FILE_WARNING } from '../../templates' -import { join } from 'path' -import { version } from '../../../package.json' -import { StandardProps, DebugContext } from '../index' -import { ErrorContext, wrapError, toUnexpectedError, WrappedError, isWrappedError } from './error' -import figures from 'figures' -import { Init } from './init' -import { getEmail } from '../config/config' -import { toTrackingPlanId } from '../api/trackingplans' -import { APIError } from '../types' - -const readFile = promisify(fs.readFile) -const readdir = promisify(fs.readdir) -const writeFile = promisify(fs.writeFile) -const unlink = promisify(fs.unlink) + fetchTrackingPlan, + loadTrackingPlan, + writeTrackingPlan, + TrackingPlanDeltas, + computeDelta, + RudderAPI, + toTrackingPlanURL, +} from '../api'; +import { gen, RawTrackingPlan } from '../../generators/gen'; +import { RUDDER_AUTOGENERATED_FILE_WARNING } from '../../templates'; +import { join } from 'path'; +import { version } from '../../../package.json'; +import { StandardProps, DebugContext } from '../index'; +import { ErrorContext, wrapError, toUnexpectedError, WrappedError, isWrappedError } from './error'; +import figures from 'figures'; +import { Init } from './init'; +import { getEmail } from '../config/config'; +import { getTrackingPlanName, toTrackingPlanId } from '../api/trackingplans'; +import { APIError } from '../types'; + +const readFile = promisify(fs.readFile); +const readdir = promisify(fs.readdir); +const writeFile = promisify(fs.writeFile); +const unlink = promisify(fs.unlink); type Props = StandardProps & { - /** Whether or not to generate a production client. */ - production: boolean - /** Whether or not to update the local `plan.json` with the latest Tracking Plan. */ - update: boolean -} + /** Whether or not to generate a production client. */ + production: boolean; + /** Whether or not to update the local `plan.json` with the latest Tracking Plan. */ + update: boolean; +}; enum Steps { - UpdatePlan = 0, - ClearFiles = 1, - Generation = 2, - After = 3, - Done = 4, + UpdatePlan = 0, + ClearFiles = 1, + Generation = 2, + After = 3, + Done = 4, } export const Build: React.FC = ({ - config: currentConfig, - configPath, - production, - update, - anonymousId, - analyticsProps, + config: currentConfig, + configPath, + production, + update, + anonymousId, + analyticsProps, }) => { - const [step, setStep] = useState(Steps.UpdatePlan) - const [trackingPlans, setTrackingPlans] = useState([]) - const [config, setConfig] = useState(currentConfig) - const { exit } = useApp() - - const onNext = () => setStep(step + 1) - function withNextStep(f: (arg: Arg) => void) { - return (arg: Arg) => { - f(arg) - setStep(step + 1) - } - } - - useEffect(() => { - if (step === Steps.Done) { - exit() - } - }, [step]) - - // If a ruddertyper.yml hasn't been configured yet, drop the user into the init wizard. - if (!config) { - return ( - - ) - } - - return ( - - - - - - - ) -} + const [step, setStep] = useState(Steps.UpdatePlan); + const [trackingPlans, setTrackingPlans] = useState([]); + const [config, setConfig] = useState(currentConfig); + const { exit } = useApp(); + + const onNext = () => setStep(step + 1); + function withNextStep(f: (arg: Arg) => void) { + return (arg: Arg) => { + f(arg); + setStep(step + 1); + }; + } + + useEffect(() => { + if (step === Steps.Done) { + exit(); + } + }, [step]); + + // If a ruddertyper.yml hasn't been configured yet, drop the user into the init wizard. + if (!config) { + return ( + + ); + } + + return ( + + + + + + + ); +}; type UpdatePlanStepProps = { - config: Config - configPath: string - update: boolean - step: number - onDone: (trackingPlans: RawTrackingPlan[]) => void -} + config: Config; + configPath: string; + update: boolean; + step: number; + onDone: (trackingPlans: RawTrackingPlan[]) => void; +}; // Load a Tracking Plan, either from the API or from the `plan.json` file. export const UpdatePlanStep: React.FC = ({ - config, - configPath, - update, - step, - onDone, + config, + configPath, + update, + step, + onDone, }) => { - const [trackingPlans, setTrackingPlans] = useState< - { trackingPlan: RawTrackingPlan; deltas: TrackingPlanDeltas }[] - >([]) - // The various warning states we enter while loading Tracking Plans: - const [failedToFindToken, setFailedToFindToken] = useState(false) - const [fellbackToUpdate, setFellbackToUpdate] = useState(false) - const [apiError, setAPIError] = useState() - const { handleFatalError, handleError } = useContext(ErrorContext) - const { isRunning, isDone } = useStep(step, Steps.UpdatePlan, loadTrackingPlans, onDone) - - async function loadTrackingPlans() { - const loadedTrackingPlans: typeof trackingPlans = [] - for (const trackingPlanConfig of config.trackingPlans) { - // Load the local copy of this Tracking Plan, we'll either use this for generation - // or use it to identify what changed with the latest copy of this Tracking Plan. - const previousTrackingPlan = await loadTrackingPlan(configPath, trackingPlanConfig) - - // If we don't have a copy of the Tracking Plan, then we would fatal error. Instead, - // fallback to pulling down a new copy of the Tracking Plan. - if (!update && !previousTrackingPlan) { - setFellbackToUpdate(true) - } - - // If we are pulling the latest Tracking Plan (npx rudder-typer), or if there is no local - // copy of the Tracking Plan (plan.json), then query the API for the latest Tracking Plan. - let newTrackingPlan: RudderAPI.TrackingPlan | undefined = undefined - if (update || !previousTrackingPlan) { - // Attempt to read a token and use it to update the local Tracking Plan to the latest version. - const token = await getToken(config, configPath) - const email = await getEmail(config, configPath) - if (token && email) { - try { - newTrackingPlan = await fetchTrackingPlan({ - id: trackingPlanConfig.id, - workspaceSlug: trackingPlanConfig.workspaceSlug, - token, - email, - }) - } catch (error) { - handleError(error as WrappedError) - if (isWrappedError(error)) { - setAPIError(error.description) - } else { - setAPIError('API request failed') - } - } - - if (newTrackingPlan) { - // Update plan.json with the latest Tracking Plan. - await writeTrackingPlan(configPath, newTrackingPlan, trackingPlanConfig) - } - } else { - setFailedToFindToken(true) - } - } - - newTrackingPlan = newTrackingPlan || previousTrackingPlan - if (!newTrackingPlan) { - handleFatalError(wrapError('Unable to fetch Tracking Plan from local cache or API')) - return null - } - - const { events } = newTrackingPlan.rules - const trackingPlan: RawTrackingPlan = { - name: newTrackingPlan.display_name, - url: toTrackingPlanURL(newTrackingPlan.name), - id: toTrackingPlanId(newTrackingPlan.name), - version: newTrackingPlan.version, - path: trackingPlanConfig.path, - trackCalls: events - // RudderTyper doesn't yet support event versioning. For now, we just choose the most recent version. - .filter(e => events.every(e2 => e.name !== e2.name || e.version >= e2.version)) - .map(e => ({ - ...e.rules, - title: e.name, - description: e.description, - })), - } - - loadedTrackingPlans.push({ - trackingPlan, - deltas: computeDelta(previousTrackingPlan, newTrackingPlan), - }) - setTrackingPlans(loadedTrackingPlans) - } - - return loadedTrackingPlans.map(({ trackingPlan }) => trackingPlan) - } - - const s = config.trackingPlans.length > 1 ? 's' : '' - const stepName = isDone ? `Loaded Tracking Plan${s}` : `Loading Tracking Plan${s}...` - return ( - - {update && Downloading the latest version{s} from Rudder...} - {fellbackToUpdate && ( - No local copy of this Tracking Plan, fetching from API. - )} - {failedToFindToken && ( - No valid API token, using local {s ? 'copies' : 'copy'} instead. - )} - {!!apiError && ( - - {apiError}. Using local {s ? 'copies' : 'copy'} instead. - - )} - {trackingPlans.map(({ trackingPlan, deltas }) => ( - - - Loaded {trackingPlan.name}{' '} - {(deltas.added !== 0 || deltas.modified !== 0 || deltas.removed !== 0) && ( - <> - ( - 0}> - {deltas.added} added - - ,{' '} - 0}> - {deltas.modified} modified - - ,{' '} - 0}> - {deltas.removed} removed - - ) - - )} - - - ))} - - ) -} + const [trackingPlans, setTrackingPlans] = useState< + { trackingPlan: RawTrackingPlan; deltas: TrackingPlanDeltas }[] + >([]); + // The various warning states we enter while loading Tracking Plans: + const [failedToFindToken, setFailedToFindToken] = useState(false); + const [fellbackToUpdate, setFellbackToUpdate] = useState(false); + const [apiError, setAPIError] = useState(); + const { handleFatalError, handleError } = useContext(ErrorContext); + const { isRunning, isDone } = useStep(step, Steps.UpdatePlan, loadTrackingPlans, onDone); + + async function loadTrackingPlans() { + const loadedTrackingPlans: typeof trackingPlans = []; + for (const trackingPlanConfig of config.trackingPlans) { + // Load the local copy of this Tracking Plan, we'll either use this for generation + // or use it to identify what changed with the latest copy of this Tracking Plan. + const previousTrackingPlan = await loadTrackingPlan(configPath, trackingPlanConfig); + + // If we don't have a copy of the Tracking Plan, then we would fatal error. Instead, + // fallback to pulling down a new copy of the Tracking Plan. + if (!update && !previousTrackingPlan) { + setFellbackToUpdate(true); + } + + // If we are pulling the latest Tracking Plan (npx rudder-typer), or if there is no local + // copy of the Tracking Plan (plan.json), then query the API for the latest Tracking Plan. + let newTrackingPlan: RudderAPI.TrackingPlan | undefined = undefined; + if (update || !previousTrackingPlan) { + // Attempt to read a token and use it to update the local Tracking Plan to the latest version. + const token = await getToken(config, configPath); + const email = await getEmail(config, configPath); + if (token && email) { + try { + newTrackingPlan = await fetchTrackingPlan({ + id: trackingPlanConfig.id, + workspaceSlug: trackingPlanConfig.workspaceSlug, + token, + email, + APIVersion: trackingPlanConfig.APIVersion, + }); + } catch (error) { + handleError(error as WrappedError); + if (isWrappedError(error)) { + setAPIError(error.description); + } else { + setAPIError('API request failed'); + } + } + + if (newTrackingPlan) { + // Update plan.json with the latest Tracking Plan. + await writeTrackingPlan(configPath, newTrackingPlan, trackingPlanConfig); + } + } else { + setFailedToFindToken(true); + } + } + newTrackingPlan = newTrackingPlan || previousTrackingPlan; + if (!newTrackingPlan) { + handleFatalError(wrapError('Unable to fetch Tracking Plan from local cache or API')); + return null; + } + + const { events } = newTrackingPlan.rules; + const trackingPlan: RawTrackingPlan = { + name: getTrackingPlanName(newTrackingPlan), + url: toTrackingPlanURL(newTrackingPlan), + id: toTrackingPlanId(newTrackingPlan), + version: newTrackingPlan.version, + path: trackingPlanConfig.path, + trackCalls: events.map(e => ({ + ...e.rules, + title: e.name, + description: e.description, + })), + }; + + loadedTrackingPlans.push({ + trackingPlan, + deltas: computeDelta(previousTrackingPlan, newTrackingPlan), + }); + setTrackingPlans(loadedTrackingPlans); + } + + return loadedTrackingPlans.map(({ trackingPlan }) => trackingPlan); + } + + const s = config.trackingPlans.length > 1 ? 's' : ''; + const stepName = isDone ? `Loaded Tracking Plan${s}` : `Loading Tracking Plan${s}...`; + return ( + + {update && Downloading the latest version{s} from Rudder...} + {fellbackToUpdate && ( + No local copy of this Tracking Plan, fetching from API. + )} + {failedToFindToken && ( + No valid API token, using local {s ? 'copies' : 'copy'} instead. + )} + {!!apiError && ( + + {apiError}. Using local {s ? 'copies' : 'copy'} instead. + + )} + {trackingPlans.map(({ trackingPlan, deltas }) => ( + + + Loaded {trackingPlan.name}{' '} + {(deltas.added !== 0 || deltas.modified !== 0 || deltas.removed !== 0) && ( + <> + ( + 0}> + {deltas.added} added + + ,{' '} + 0}> + {deltas.modified} modified + + ,{' '} + 0}> + {deltas.removed} removed + + ) + + )} + + + ))} + + ); +}; type ClearFilesProps = { - config: Config - configPath: string - step: number - onDone: () => void -} + config: Config; + configPath: string; + step: number; + onDone: () => void; +}; export const ClearFilesStep: React.FC = ({ config, configPath, step, onDone }) => { - const { handleFatalError } = useContext(ErrorContext) - const { isRunning, isDone } = useStep(step, Steps.ClearFiles, clearGeneratedFiles, onDone) - - async function clearGeneratedFiles() { - const errors = await Promise.all( - config.trackingPlans.map(async trackingPlanConfig => { - const path = resolveRelativePath(configPath, trackingPlanConfig.path) - await verifyDirectoryExists(path) - try { - await clearFolder(path) - } catch (error) { - const err = error as Error; - return wrapError( - 'Failed to clear generated files', - err, - `Failed on: '${trackingPlanConfig.path}'`, - err.message - ) - } - }) - ) - - const error = errors.find(error => isWrappedError(error)) - if (error) { - handleFatalError(error) - return null - } - } - - // clearFolder removes all ruddertyper-generated files from the specified folder - // except for a plan.json. - // It uses a simple heuristic to avoid accidentally clobbering a user's files -- - // it only clears files with the "this file was autogenerated by RudderTyper" warning. - // Therefore, all generators need to output that warning in a comment in the first few - // lines of every generated file. - async function clearFolder(path: string): Promise { - const fileNames = await readdir(path, 'utf-8') - for (const fileName of fileNames) { - const fullPath = join(path, fileName) - try { - const contents = await readFile(fullPath, 'utf-8') - if (contents.includes(RUDDER_AUTOGENERATED_FILE_WARNING)) { - await unlink(fullPath) - } - } catch (error) { - const err = error as APIError; - // Note: none of our generators produce folders, but if we ever do, then we'll need to - // update this logic to handle recursively traversing directores. For now, we just ignore - // any directories. - if (err.code !== 'EISDIR') { - throw error - } - } - } - } - - const stepName = isDone ? 'Removed generated files' : 'Removing generated files...' - return -} + const { handleFatalError } = useContext(ErrorContext); + const { isRunning, isDone } = useStep(step, Steps.ClearFiles, clearGeneratedFiles, onDone); + + async function clearGeneratedFiles() { + const errors = await Promise.all( + config.trackingPlans.map(async trackingPlanConfig => { + const path = resolveRelativePath(configPath, trackingPlanConfig.path); + await verifyDirectoryExists(path); + try { + await clearFolder(path); + } catch (error) { + const err = error as Error; + return wrapError( + 'Failed to clear generated files', + err, + `Failed on: '${trackingPlanConfig.path}'`, + err.message, + ); + } + }), + ); + + const error = errors.find(error => isWrappedError(error)); + if (error) { + handleFatalError(error); + return null; + } + } + + // clearFolder removes all ruddertyper-generated files from the specified folder + // except for a plan.json. + // It uses a simple heuristic to avoid accidentally clobbering a user's files -- + // it only clears files with the "this file was autogenerated by RudderTyper" warning. + // Therefore, all generators need to output that warning in a comment in the first few + // lines of every generated file. + async function clearFolder(path: string): Promise { + const fileNames = await readdir(path, 'utf-8'); + for (const fileName of fileNames) { + const fullPath = join(path, fileName); + try { + const contents = await readFile(fullPath, 'utf-8'); + if (contents.includes(RUDDER_AUTOGENERATED_FILE_WARNING)) { + await unlink(fullPath); + } + } catch (error) { + const err = error as APIError; + // Note: none of our generators produce folders, but if we ever do, then we'll need to + // update this logic to handle recursively traversing directores. For now, we just ignore + // any directories. + if (err.code !== 'EISDIR') { + throw error; + } + } + } + } + + const stepName = isDone ? 'Removed generated files' : 'Removing generated files...'; + return ; +}; type GenerationProps = { - config: Config - configPath: string - production: boolean - trackingPlans: RawTrackingPlan[] - step: number - onDone: () => void -} + config: Config; + configPath: string; + production: boolean; + trackingPlans: RawTrackingPlan[]; + step: number; + onDone: () => void; +}; export const GenerationStep: React.FC = ({ - config, - configPath, - production, - trackingPlans, - step, - onDone, + config, + configPath, + production, + trackingPlans, + step, + onDone, }) => { - const { isRunning, isDone } = useStep(step, Steps.Generation, generate, onDone) - - async function generate() { - for (const trackingPlan of trackingPlans) { - // Generate the client: - const files = await gen(trackingPlan, { - client: config.client, - rudderTyperVersion: version, - isDevelopment: !production, - }) - - // Write it out to the specified directory: - for (const file of files) { - const path = resolveRelativePath(configPath, trackingPlan.path, file.path) - await verifyDirectoryExists(path, 'file') - await writeFile(path, file.contents, { - encoding: 'utf-8', - }) - } - } - } - - const s = config.trackingPlans.length > 1 ? 's' : '' - const stepName = isDone ? `Generated client${s}` : `Generating client${s}...` - return ( - - Building for {production ? 'production' : 'development'} - {trackingPlans.map(trackingPlan => ( - - {trackingPlan.name} - - ))} - - ) -} + const { isRunning, isDone } = useStep(step, Steps.Generation, generate, onDone); + + async function generate() { + for (const trackingPlan of trackingPlans) { + // Generate the client: + const files = await gen(trackingPlan, { + client: config.client, + rudderTyperVersion: version, + isDevelopment: !production, + }); + + // Write it out to the specified directory: + for (const file of files) { + const path = resolveRelativePath(configPath, trackingPlan.path, file.path); + await verifyDirectoryExists(path, 'file'); + await writeFile(path, file.contents, { + encoding: 'utf-8', + }); + } + } + } + + const s = config.trackingPlans.length > 1 ? 's' : ''; + const stepName = isDone ? `Generated client${s}` : `Generating client${s}...`; + return ( + + Building for {production ? 'production' : 'development'} + {trackingPlans.map(trackingPlan => ( + + {trackingPlan.name} + + ))} + + ); +}; type AfterStepProps = { - config: Config - configPath: string - step: number - onDone: () => void -} + config: Config; + configPath: string; + step: number; + onDone: () => void; +}; export const AfterStep: React.FC = ({ config, configPath, step, onDone }) => { - const { handleError } = useContext(ErrorContext) - const [error, setError] = useState() - const { isRunning, isDone } = useStep(step, Steps.After, after, onDone) - - const afterScript = config.scripts ? config.scripts.after : undefined - - async function after() { - if (afterScript) { - try { - await runScript(afterScript, configPath, Scripts.After) - } catch (error) { - if (isWrappedError(error)) { - handleError(error) - setError(error) - } else { - throw error - } - } - } - } - - const stepName = isDone ? 'Cleaned up' : 'Running clean up script...' - return ( - - {afterScript && {afterScript}} - {error && ( - <> - {error.description} - {error.notes - .filter(n => !!n) - .map(n => ( - - {n} - - ))} - - )} - - ) -} + const { handleError } = useContext(ErrorContext); + const [error, setError] = useState(); + const { isRunning, isDone } = useStep(step, Steps.After, after, onDone); + + const afterScript = config.scripts ? config.scripts.after : undefined; + + async function after() { + if (afterScript) { + try { + await runScript(afterScript, configPath, Scripts.After); + } catch (error) { + if (isWrappedError(error)) { + handleError(error); + setError(error); + } else { + throw error; + } + } + } + } + + const stepName = isDone ? 'Cleaned up' : 'Running clean up script...'; + return ( + + {afterScript && {afterScript}} + {error && ( + <> + {error.description} + {error.notes + .filter(n => !!n) + .map(n => ( + + {n} + + ))} + + )} + + ); +}; function useStep( - step: Steps, - thisStep: Steps, - f: () => Promise, - onDone: (arg: Arg) => void + step: Steps, + thisStep: Steps, + f: () => Promise, + onDone: (arg: Arg) => void, ) { - const { handleFatalError } = useContext(ErrorContext) - const isRunning = step === thisStep - - async function runStep() { - try { - const result = await f() - // If a fatal error occurred, return null to skip any further updates to this component. - if (result !== null) { - onDone(result) - } - } catch (error) { - handleFatalError(toUnexpectedError(error as Error)) - } - } - - useEffect(() => { - if (isRunning) { - runStep() - } - }, [isRunning]) - - return { - isRunning, - isDone: step > thisStep, - } + const { handleFatalError } = useContext(ErrorContext); + const isRunning = step === thisStep; + + async function runStep() { + try { + const result = await f(); + // If a fatal error occurred, return null to skip any further updates to this component. + if (result !== null) { + onDone(result); + } + } catch (error) { + handleFatalError(toUnexpectedError(error as Error)); + } + } + + useEffect(() => { + if (isRunning) { + runStep(); + } + }, [isRunning]); + + return { + isRunning, + isDone: step > thisStep, + }; } type StepProps = { - name: string - isSkipped?: boolean - isRunning: boolean - isDone: boolean -} + name: string; + isSkipped?: boolean; + isRunning: boolean; + isDone: boolean; +}; const Step: React.FC = ({ name, isSkipped, isRunning, isDone, children }) => { - const { debug } = useContext(DebugContext) - - if (isSkipped) { - return null - } - - return ( - - - - {/* In debug mode, skip the Spinner to reduce noise */} - {isDone ? ( - ✔ - ) : isRunning ? ( - debug ? ( - figures.ellipsis - ) : ( - - ) - ) : ( - '' - )} - - - {name} - - - {(isRunning || isDone) && children} - - ) -} + const { debug } = useContext(DebugContext); + + if (isSkipped) { + return null; + } + + return ( + + + + {/* In debug mode, skip the Spinner to reduce noise */} + {isDone ? ( + ✔ + ) : isRunning ? ( + debug ? ( + figures.ellipsis + ) : ( + + ) + ) : ( + '' + )} + + + {name} + + + {(isRunning || isDone) && children} + + ); +}; type NoteProps = { - isWarning?: boolean -} + isWarning?: boolean; +}; const Note: React.FC = ({ isWarning, children }) => { - return ( - - - {isWarning ? '⚠' : '↪'} - - {children} - - - - ) -} + return ( + + + {isWarning ? '⚠' : '↪'} + + {children} + + + + ); +}; diff --git a/src/cli/commands/error.tsx b/src/cli/commands/error.tsx index f9570f6a..9457959f 100644 --- a/src/cli/commands/error.tsx +++ b/src/cli/commands/error.tsx @@ -1,64 +1,64 @@ -import React, { createContext, useEffect } from 'react' -import { Box, Color, useApp } from 'ink' -import Link from 'ink-link' -import figures from 'figures' -import { version } from '../../../package.json' -import { AnalyticsProps } from '../index' +import React, { createContext, useEffect } from 'react'; +import { Box, Color, useApp } from 'ink'; +import Link from 'ink-link'; +import figures from 'figures'; +import { version } from '../../../package.json'; +import { AnalyticsProps } from '../index'; type ErrorContextProps = { - /** Called to indicate that a non-fatal error has occurred. This will be printed only in debug mode. */ - handleError: (error: WrappedError) => void - /** Called to indicate that a fatal error has occurred, which will render the Error component. */ - handleFatalError: (error: WrappedError) => void -} + /** Called to indicate that a non-fatal error has occurred. This will be printed only in debug mode. */ + handleError: (error: WrappedError) => void; + /** Called to indicate that a fatal error has occurred, which will render the Error component. */ + handleFatalError: (error: WrappedError) => void; +}; export const ErrorContext = createContext({ - handleError: () => {}, - handleFatalError: () => {}, -}) + handleError: () => {}, + handleFatalError: () => {}, +}); export type WrappedError = { - isWrappedError: true - description: string - notes: string[] - error?: Error -} + isWrappedError: true; + description: string; + notes: string[]; + error?: Error; +}; /** Helper to wrap an error with a human-readable description. */ export function wrapError(description: string, error?: Error, ...notes: string[]): WrappedError { - return { - isWrappedError: true, - description, - notes, - error, - } + return { + isWrappedError: true, + description, + notes, + error, + }; } export function isWrappedError(error: unknown): error is WrappedError { - return !!error && typeof error === 'object' && (error as Record).isWrappedError + return !!error && typeof error === 'object' && (error as Record).isWrappedError; } export function toUnexpectedError(error: Error): WrappedError { - if (isWrappedError(error)) { - return error - } + if (isWrappedError(error)) { + return error; + } - return wrapError('An unexpected error occurred.', error, error.message) + return wrapError('An unexpected error occurred.', error, error.message); } type ErrorBoundaryProps = AnalyticsProps & { - /** - * If an error is passed as a prop, then it's considered a fatal error. - * Most errors will be raised via getDerivedStateFromError or the ErrorContext - * handlers, however errors that happen outside of the render path can force - * an error to be rendered via this prop. - */ - error?: Error - debug: boolean -} + /** + * If an error is passed as a prop, then it's considered a fatal error. + * Most errors will be raised via getDerivedStateFromError or the ErrorContext + * handlers, however errors that happen outside of the render path can force + * an error to be rendered via this prop. + */ + error?: Error; + debug: boolean; +}; type ErrorBoundaryState = { - error?: WrappedError -} + error?: WrappedError; +}; /** * We use a class component here, because we need access to the getDerivedStateFromError @@ -69,111 +69,112 @@ type ErrorBoundaryState = { * the error-ed component. See: https://github.com/vadimdemedes/ink/issues/234 */ export class ErrorBoundary extends React.Component { - public state: ErrorBoundaryState = {} - - public static getDerivedStateFromError(error: Error): Partial { - return { error: toUnexpectedError(error) } - } - - public componentDidCatch(error: Error): void { - this.reportError({ - error: toUnexpectedError(error), - fatal: true, - }) - } - - public componentDidMount(): void { - if (this.props.error) { - const err = toUnexpectedError(this.props.error) - this.reportError({ - error: err, - fatal: true, - }) - this.setState({ error: err }) - } - } - - private reportError = async (params: { error: WrappedError; fatal: boolean }) => { - if (this.props.debug) { - console.trace(params.error) - } - } - - /** For non-fatal errors, we just log the error when in debug mode. */ - private handleError = async (error: WrappedError) => { - await this.reportError({ - error, - fatal: false, - }) - } - - /** For fatal errors, we halt the CLI by rendering an ErrorComponent. */ - private handleFatalError = async (error: WrappedError) => { - await this.reportError({ - error, - fatal: true, - }) - this.setState({ error }) - } - - public render(): JSX.Element { - const { children } = this.props - const { error } = this.state - - const context = { - handleError: this.handleError, - handleFatalError: this.handleFatalError, - } - - return ( - - - {error && } - {!error && children} - - - ) - } + public state: ErrorBoundaryState = {}; + + public static getDerivedStateFromError(error: Error): Partial { + return { error: toUnexpectedError(error) }; + } + + public componentDidCatch(error: Error): void { + this.reportError({ + error: toUnexpectedError(error), + fatal: true, + }); + } + + public componentDidMount(): void { + if (this.props.error) { + const err = toUnexpectedError(this.props.error); + this.reportError({ + error: err, + fatal: true, + }); + this.setState({ error: err }); + } + } + + private reportError = async (params: { error: WrappedError; fatal: boolean }) => { + if (this.props.debug) { + console.trace(params.error); + } + }; + + /** For non-fatal errors, we just log the error when in debug mode. */ + private handleError = async (error: WrappedError) => { + await this.reportError({ + error, + fatal: false, + }); + }; + + /** For fatal errors, we halt the CLI by rendering an ErrorComponent. */ + private handleFatalError = async (error: WrappedError) => { + await this.reportError({ + error, + fatal: true, + }); + this.setState({ error }); + }; + + public render(): JSX.Element { + const { children } = this.props; + const { error } = this.state; + + const context = { + handleError: this.handleError, + handleFatalError: this.handleFatalError, + }; + + return ( + + + {error && } + {!error && children} + + + ); + } } type ErrorComponentProps = { - error: WrappedError -} + error: WrappedError; +}; const ErrorComponent: React.FC = ({ error }) => { - const { exit } = useApp() - // Wrap the call to `exit` in a `useEffect` so that it fires after rendering. - useEffect(() => { - exit(error.error) - }, []) - - return ( - - - - {figures.cross} Error: {error.description} - - - {error.notes && - error.notes.map(n => ( - - - {figures.arrowRight} - - - {n} - - - ))} - - - If you are unable to resolve this issue,{' '} - - open an issue on GitHub - - . Please include that you are using version {version} of RudderTyper. - - - - ) -} + const { exit } = useApp(); + // Wrap the call to `exit` in a `useEffect` so that it fires after rendering. + useEffect(() => { + exit(error.error); + }, []); + + return ( + + + + {figures.cross} Error: {error.description} + + + {error.notes && + error.notes.map(n => ( + + + {figures.arrowRight} + + + {n} + + + ))} + + + If you are unable to resolve this issue,{' '} + + open an issue on GitHub + + . Please include that you are using version {version} of + RudderTyper. + + + + ); +}; diff --git a/src/cli/commands/help.tsx b/src/cli/commands/help.tsx index bedd1263..b4d0ade3 100644 --- a/src/cli/commands/help.tsx +++ b/src/cli/commands/help.tsx @@ -2,165 +2,171 @@ * Help layout inspired by Zeit's Now CLI. * https://zeit.co */ -import React, { useEffect } from 'react' -import { Box, Color, Text, useApp } from 'ink' -import Link from 'ink-link' -import { StandardProps } from '../index' +import React, { useEffect } from 'react'; +import { Box, Color, Text, useApp } from 'ink'; +import Link from 'ink-link'; +import { StandardProps } from '../index'; export const Help: React.FC = () => { - const { exit } = useApp() - useEffect(() => { - exit() - }, []) + const { exit } = useApp(); + useEffect(() => { + exit(); + }, []); - return ( - - - - RudderTyper is a tool for generating strongly-typed{' '} - RudderStack analytics libraries based on your - pre-defined Tracking Plan spec. - {'\n\n'} - - - - - $ rudder-typer [command, options] - - - - Quickstart wizard to create a ruddertyper.yml - - } - /> - - Syncs plan.json with RudderStack, then generates a{' '} - development client. - - } - /> - - Generates a development client from{' '} - plan.json - - } - /> - - Generates a production client from{' '} - plan.json - - } - /> - - - - - - - Path to a ruddertyper.yml file - - } - /> - {/* NOTE: we only show the --debug flag when developing locally on RudderTyper. */} - - - - - - - - - - - ) -} + return ( + + + + RudderTyper is a tool for generating strongly-typed{' '} + RudderStack analytics libraries based on your + pre-defined Tracking Plan spec. + {'\n\n'} + + + + + $ rudder-typer [command, options] + + + + Quickstart wizard to create a ruddertyper.yml + + } + /> + + Syncs plan.json with RudderStack, then generates a{' '} + development client. + + } + /> + + Generates a development client from{' '} + plan.json + + } + /> + + Generates a production client from{' '} + plan.json + + } + /> + + + + + + + Path to a ruddertyper.yml file + + } + /> + {/* NOTE: we only show the --debug flag when developing locally on RudderTyper. */} + + + + + + + + + + + ); +}; type HelpSectionProps = { - name: string -} + name: string; +}; const HelpSection: React.FC = ({ name, children }) => { - return ( - - {name}: - - {children} - - - ) -} + return ( + + {name}: + + {children} + + + ); +}; type HelpRowProps = { - name: string - isDefault?: boolean - description: string | JSX.Element - linesNeeded?: number - isHidden?: boolean -} + name: string; + isDefault?: boolean; + description: string | JSX.Element; + linesNeeded?: number; + isHidden?: boolean; +}; const HelpRow: React.FC = ({ - name, - description, - isDefault, - linesNeeded, - isHidden, + name, + description, + isDefault, + linesNeeded, + isHidden, }) => { - if (!!isHidden) { - return null - } + if (!!isHidden) { + return null; + } - return ( - - {name} - - {description} - - {!!isDefault ? (default) : ''} - - ) -} + return ( + + {name} + + {description} + + {!!isDefault ? (default) : ''} + + ); +}; type ExampleRowProps = { - description: string - command: string -} + description: string; + command: string; +}; const ExampleRow: React.FC = ({ description, command }) => { - return ( - - {description} - - $ {command} - - - ) -} + return ( + + {description} + + $ {command} + + + ); +}; -Help.displayName = 'Help' +Help.displayName = 'Help'; diff --git a/src/cli/commands/index.ts b/src/cli/commands/index.ts index 76af2488..e9b092b2 100644 --- a/src/cli/commands/index.ts +++ b/src/cli/commands/index.ts @@ -1,6 +1,6 @@ -export { Token } from './token' -export { Version } from './version' -export { Build } from './build' -export { Help } from './help' -export { Init } from './init' -export { ErrorBoundary, wrapError } from './error' +export { Token } from './token'; +export { Version } from './version'; +export { Build } from './build'; +export { Help } from './help'; +export { Init } from './init'; +export { ErrorBoundary, wrapError } from './error'; diff --git a/src/cli/commands/init.tsx b/src/cli/commands/init.tsx index 3514555c..10dbef73 100644 --- a/src/cli/commands/init.tsx +++ b/src/cli/commands/init.tsx @@ -1,856 +1,867 @@ -import React, { useState, useEffect, useContext } from 'react' -import { Text, Box, Color, useApp } from 'ink' -import Link from 'ink-link' -import SelectInput, { Item } from 'ink-select-input' -import TextInput from 'ink-text-input' -import Spinner from 'ink-spinner' -import { Config, listTokens, getTokenMethod, setConfig, storeToken } from '../config' +import React, { useState, useEffect, useContext } from 'react'; +import { Text, Box, Color, useApp } from 'ink'; +import Link from 'ink-link'; +import SelectInput, { Item } from 'ink-select-input'; +import TextInput from 'ink-text-input'; +import Spinner from 'ink-spinner'; +import { Config, listTokens, getTokenMethod, setConfig, storeToken } from '../config'; import { - validateToken, - RudderAPI, - fetchTrackingPlans, - toTrackingPlanURL, - parseTrackingPlanName, -} from '../api' -import { SDK, Language, Options, JavaScriptOptions } from '../../generators/options' -import figures from 'figures' -import * as fs from 'fs' -import { promisify } from 'util' -import { join, normalize } from 'path' -import { orderBy } from 'lodash' -import { Build } from './build' -import Fuse from 'fuse.js' -import { StandardProps, DebugContext } from '../index' -import { ErrorContext, WrappedError, wrapError } from './error' -import { APIError } from '../types' - -const readir = promisify(fs.readdir) + validateToken, + RudderAPI, + fetchTrackingPlans, + toTrackingPlanURL, + parseTrackingPlanName, +} from '../api'; +import { SDK, Language, Options, JavaScriptOptions } from '../../generators/options'; +import figures from 'figures'; +import * as fs from 'fs'; +import { promisify } from 'util'; +import { join, normalize } from 'path'; +import { orderBy } from 'lodash'; +import { Build } from './build'; +import Fuse from 'fuse.js'; +import { StandardProps, DebugContext } from '../index'; +import { ErrorContext, WrappedError, wrapError } from './error'; +import { APIError } from '../types'; +import { getTrackingPlanName } from '../api/trackingplans'; + +const readir = promisify(fs.readdir); type InitProps = StandardProps & { - /** - * Optional handler that is fired after the init wizard finishes building a new config. - * - * Defaults to running a build with the new config. - */ - onDone?: (config: Config) => void -} + /** + * Optional handler that is fired after the init wizard finishes building a new config. + * + * Defaults to running a build with the new config. + */ + onDone?: (config: Config) => void; +}; enum Steps { - Confirmation = 0, - SDK = 1, - Language = 2, - APIToken = 3, - TrackingPlan = 4, - Path = 5, - Summary = 6, - Build = 7, - Done = 8, + Confirmation = 0, + SDK = 1, + Language = 2, + APIToken = 3, + TrackingPlan = 4, + Path = 5, + Summary = 6, + Build = 7, + Done = 8, } export const Init: React.FC = props => { - const { configPath } = props - const [config, setConfig] = useState(props.config) - const [step, setStep] = useState(Steps.Confirmation) - const [sdk, setSDK] = useState(config ? config.client.sdk : SDK.WEB) - const [language, setLanguage] = useState(config ? config.client.language : Language.JAVASCRIPT) - const [path, setPath] = useState( - config && config.trackingPlans.length > 0 ? config.trackingPlans[0].path : '' - ) - const [tokenMetadata, setTokenMetadata] = useState({ - token: '', - email: '', - workspace: undefined as RudderAPI.Workspace | undefined, - }) - const [trackingPlan, setTrackingPlan] = useState() - - const { exit } = useApp() - useEffect(() => { - if (!props.onDone && step === Steps.Done) { - exit() - } - }, [step]) - - const onNext = () => setStep(step + 1) - const onRestart = () => { - setStep(Steps.SDK) - } - - function withNextStep(f?: (arg: Arg) => void) { - return (arg: Arg) => { - if (f) { - f(arg) - } - setStep(step + 1) - } - } - - function onAcceptSummary(config: Config) { - setConfig(config) - onNext() - if (props.onDone) { - props.onDone(config) - } - } - - return ( - - {step === Steps.Confirmation && } - {step === Steps.SDK && } - {step === Steps.Language && ( - - )} - {step === Steps.APIToken && ( - - )} - {step === Steps.TrackingPlan && ( - - )} - {step === Steps.Path && ( - - )} - {step === Steps.Summary && ( - - )} - {step === Steps.Build && !props.onDone && ( - - )} - {/* TODO: step 8 where we show an example script showing how to import ruddertyper */} - - ) -} + const { configPath } = props; + const [config, setConfig] = useState(props.config); + const [step, setStep] = useState(Steps.Confirmation); + const [sdk, setSDK] = useState(config ? config.client.sdk : SDK.WEB); + const [language, setLanguage] = useState(config ? config.client.language : Language.JAVASCRIPT); + const [path, setPath] = useState( + config && config.trackingPlans.length > 0 ? config.trackingPlans[0].path : '', + ); + const [tokenMetadata, setTokenMetadata] = useState({ + token: '', + email: '', + workspace: undefined as RudderAPI.Workspace | undefined, + }); + const [trackingPlan, setTrackingPlan] = useState(); + + const { exit } = useApp(); + useEffect(() => { + if (!props.onDone && step === Steps.Done) { + exit(); + } + }, [step]); + + const onNext = () => setStep(step + 1); + const onRestart = () => { + setStep(Steps.SDK); + }; + + function withNextStep(f?: (arg: Arg) => void) { + return (arg: Arg) => { + if (f) { + f(arg); + } + setStep(step + 1); + }; + } + + function onAcceptSummary(config: Config) { + setConfig(config); + onNext(); + if (props.onDone) { + props.onDone(config); + } + } + + return ( + + {step === Steps.Confirmation && } + {step === Steps.SDK && } + {step === Steps.Language && ( + + )} + {step === Steps.APIToken && ( + + )} + {step === Steps.TrackingPlan && ( + + )} + {step === Steps.Path && ( + + )} + {step === Steps.Summary && ( + + )} + {step === Steps.Build && !props.onDone && ( + + )} + {/* TODO: step 8 where we show an example script showing how to import ruddertyper */} + + ); +}; const Header: React.FC = () => { - return ( - - - - RudderTyper is a tool for generating strongly-typed{' '} - RudderStack analytics libraries from a Tracking Plan - {' '} - - . To get started, {"you'll"} need a ruddertyper.yml. The quickstart - below will walk you through creating one. - - - - ) -} + return ( + + + + RudderTyper is a tool for generating strongly-typed{' '} + RudderStack analytics libraries from a Tracking + Plan + {' '} + + . To get started, {"you'll"} need a ruddertyper.yml. The quickstart + below will walk you through creating one. + + + + ); +}; type ConfirmationPromptProps = { - onSubmit: () => void -} + onSubmit: () => void; +}; /** A simple prompt to get users acquainted with the terminal-based select. */ const ConfirmationPrompt: React.FC = ({ onSubmit }) => { - const items = [{ label: 'Ok!', value: 'ok' }] - - const tips = ['Hit return to continue.'] - - return ( - <> -
- - - - - ) -} + const items = [{ label: 'Ok!', value: 'ok' }]; + + const tips = ['Hit return to continue.']; + + return ( + <> +
+ + + + + ); +}; type SDKPromptProps = { - step: number - sdk: SDK - onSubmit: (sdk: SDK) => void -} + step: number; + sdk: SDK; + onSubmit: (sdk: SDK) => void; +}; /** A prompt to identify which RudderStack SDK a user wants to use. */ const SDKPrompt: React.FC = ({ step, sdk, onSubmit }) => { - const items: Item[] = [ - { label: 'Web (analytics.js)', value: SDK.WEB }, - { label: 'Node.js (analytics-node)', value: SDK.NODE }, - { label: 'iOS (analytics-ios)', value: SDK.IOS }, - { label: 'Android (analytics-android)', value: SDK.ANDROID }, - ] - const initialIndex = items.findIndex(i => i.value === sdk) - - const onSelect = (item: Item) => { - onSubmit(item.value as SDK) - } - - const tips = [ - 'Use your arrow keys to select.', - 'RudderTyper clients are strongly-typed wrappers around a RudderStack SDK.', - - To learn more about {"RudderStack's"} SDKs, see the{' '} - documentation. - , - ] - - return ( - - - - ) -} + const items: Item[] = [ + { label: 'Web (analytics.js)', value: SDK.WEB }, + { label: 'Node.js (analytics-node)', value: SDK.NODE }, + { label: 'iOS (analytics-ios)', value: SDK.IOS }, + { label: 'Android (analytics-android)', value: SDK.ANDROID }, + ]; + const initialIndex = items.findIndex(i => i.value === sdk); + + const onSelect = (item: Item) => { + onSubmit(item.value as SDK); + }; + + const tips = [ + 'Use your arrow keys to select.', + 'RudderTyper clients are strongly-typed wrappers around a RudderStack SDK.', + + To learn more about {"RudderStack's"} SDKs, see the{' '} + documentation. + , + ]; + + return ( + + + + ); +}; type LanguagePromptProps = { - step: number - sdk: SDK - language: Language - onSubmit: (language: Language) => void -} + step: number; + sdk: SDK; + language: Language; + onSubmit: (language: Language) => void; +}; /** A prompt to identify which RudderStack programming language a user wants to use. */ const LanguagePrompt: React.FC = ({ step, sdk, language, onSubmit }) => { - const items: Item[] = [ - { label: 'JavaScript', value: Language.JAVASCRIPT }, - { label: 'TypeScript', value: Language.TYPESCRIPT }, - { label: 'Objective-C', value: Language.OBJECTIVE_C }, - { label: 'Swift', value: Language.SWIFT }, - { label: 'Java', value: Language.JAVA }, - ].filter(item => { - // Filter out items that aren't relevant, given the selected SDK. - const supportedLanguages = { - [SDK.WEB]: [Language.JAVASCRIPT, Language.TYPESCRIPT], - [SDK.NODE]: [Language.JAVASCRIPT, Language.TYPESCRIPT], - [SDK.IOS]: [Language.OBJECTIVE_C, Language.SWIFT], - [SDK.ANDROID]: [Language.JAVA], - } - - return supportedLanguages[sdk].includes(item.value) - }) - const initialIndex = items.findIndex(i => i.value === language) - - const onSelect = (item: Item) => { - onSubmit(item.value as Language) - } - - return ( - - - - ) -} + const items: Item[] = [ + { label: 'JavaScript', value: Language.JAVASCRIPT }, + { label: 'TypeScript', value: Language.TYPESCRIPT }, + { label: 'Objective-C', value: Language.OBJECTIVE_C }, + { label: 'Swift', value: Language.SWIFT }, + { label: 'Java', value: Language.JAVA }, + ].filter(item => { + // Filter out items that aren't relevant, given the selected SDK. + const supportedLanguages = { + [SDK.WEB]: [Language.JAVASCRIPT, Language.TYPESCRIPT], + [SDK.NODE]: [Language.JAVASCRIPT, Language.TYPESCRIPT], + [SDK.IOS]: [Language.OBJECTIVE_C, Language.SWIFT], + [SDK.ANDROID]: [Language.JAVA], + }; + + return supportedLanguages[sdk].includes(item.value); + }); + const initialIndex = items.findIndex(i => i.value === language); + + const onSelect = (item: Item) => { + onSubmit(item.value as Language); + }; + + return ( + + + + ); +}; type PathPromptProps = { - step: number - path: string - onSubmit: (path: string) => void -} + step: number; + path: string; + onSubmit: (path: string) => void; +}; /** Helper to list and filter all directories under a given filesystem path. */ async function filterDirectories(path: string): Promise { - /** Helper to list all directories in a given path. */ - const listDirectories = async (path: string): Promise => { - try { - const files = await readir(path, { - withFileTypes: true, - }) - const directoryBlocklist = ['node_modules'] - return files - .filter(f => f.isDirectory()) - .filter(f => !f.name.startsWith('.')) - .filter(f => !directoryBlocklist.some(b => f.name.startsWith(b))) - .map(f => join(path, f.name)) - .filter(f => normalize(f).startsWith(normalize(path).replace(/^\.\/?/, ''))) - } catch { - // If we can't read this path, then return an empty list of sub-directories. - return [] - } - } - - const isPathEmpty = ['', '.', './'].includes(path) - const directories = new Set() - - // First look for all directories in the same directory as the current query path. - const parentPath = join(path, isPathEmpty || path.endsWith('/') ? '.' : '..') - const parentDirectories = await listDirectories(parentPath) - parentDirectories.forEach(f => directories.add(f)) - - const queryPath = join(parentPath, path) - // Next, if the current query IS a directory, then we want to prioritize results from inside that directory. - if (directories.has(queryPath)) { - const queryDirectories = await listDirectories(queryPath) - queryDirectories.forEach(f => directories.add(f)) - } - - // Otherwise, show results from inside any other directories at the level of the current query path. - for (const dirPath of parentDirectories) { - if (directories.size >= 10) { - break - } - - const otherDirectories = await listDirectories(dirPath) - otherDirectories.forEach(f => directories.add(f)) - } - - // Now sort these directories by the query path. - const fuse = new Fuse( - [...directories].map(d => ({ name: d })), - { keys: ['name'] } - ) - return isPathEmpty ? [...directories] : fuse.search(path).map(d => d.name) + /** Helper to list all directories in a given path. */ + const listDirectories = async (path: string): Promise => { + try { + const files = await readir(path, { + withFileTypes: true, + }); + const directoryBlocklist = ['node_modules']; + return files + .filter(f => f.isDirectory()) + .filter(f => !f.name.startsWith('.')) + .filter(f => !directoryBlocklist.some(b => f.name.startsWith(b))) + .map(f => join(path, f.name)) + .filter(f => normalize(f).startsWith(normalize(path).replace(/^\.\/?/, ''))); + } catch { + // If we can't read this path, then return an empty list of sub-directories. + return []; + } + }; + + const isPathEmpty = ['', '.', './'].includes(path); + const directories = new Set(); + + // First look for all directories in the same directory as the current query path. + const parentPath = join(path, isPathEmpty || path.endsWith('/') ? '.' : '..'); + const parentDirectories = await listDirectories(parentPath); + parentDirectories.forEach(f => directories.add(f)); + + const queryPath = join(parentPath, path); + // Next, if the current query IS a directory, then we want to prioritize results from inside that directory. + if (directories.has(queryPath)) { + const queryDirectories = await listDirectories(queryPath); + queryDirectories.forEach(f => directories.add(f)); + } + + // Otherwise, show results from inside any other directories at the level of the current query path. + for (const dirPath of parentDirectories) { + if (directories.size >= 10) { + break; + } + + const otherDirectories = await listDirectories(dirPath); + otherDirectories.forEach(f => directories.add(f)); + } + + // Now sort these directories by the query path. + const fuse = new Fuse( + [...directories].map(d => ({ name: d })), + { keys: ['name'] }, + ); + return isPathEmpty ? [...directories] : fuse.search(path).map(d => d.name); } /** A prompt to identify where to store the new client on the user's filesystem. */ const PathPrompt: React.FC = ({ step, path: initialPath, onSubmit }) => { - const [path, setPath] = useState(initialPath) - const [directories, setDirectories] = useState([]) - - // Fetch a list of directories, filtering by the supplied path. - useEffect(() => { - ;(async () => { - let directories: string[] = [] - try { - directories = await filterDirectories(path) - } catch (err) { - console.error(err) - } - - setDirectories(directories) - })() - }, [path]) - - const tips = [ - 'The generated client will be stored in this directory.', - 'Start typing to filter existing directories. Hit return to submit.', - 'Directories will be automatically created, if needed.', - ] - - const onSubmitPath = () => { - onSubmit(normalize(path)) - } - - const isNewDirectory = - !['', '.', './'].includes(normalize(path)) && !directories.includes(normalize(path)) - const directoryRows: (string | JSX.Element)[] = isNewDirectory - ? [ - - {path} (new) - , - ] - : [] - directoryRows.push(...directories.slice(0, 10 - directoryRows.length)) - - return ( - - - {figures.pointer}{' '} - - - - {directoryRows.map((d, i) => ( - - {d} - - ))} - - - ) -} + const [path, setPath] = useState(initialPath); + const [directories, setDirectories] = useState([]); + + // Fetch a list of directories, filtering by the supplied path. + useEffect(() => { + (async () => { + let directories: string[] = []; + try { + directories = await filterDirectories(path); + } catch (err) { + console.error(err); + } + + setDirectories(directories); + })(); + }, [path]); + + const tips = [ + 'The generated client will be stored in this directory.', + 'Start typing to filter existing directories. Hit return to submit.', + 'Directories will be automatically created, if needed.', + ]; + + const onSubmitPath = () => { + onSubmit(normalize(path)); + }; + + const isNewDirectory = + !['', '.', './'].includes(normalize(path)) && !directories.includes(normalize(path)); + const directoryRows: (string | JSX.Element)[] = isNewDirectory + ? [ + + {path} (new) + , + ] + : []; + directoryRows.push(...directories.slice(0, 10 - directoryRows.length)); + + return ( + + + {figures.pointer}{' '} + + + + {directoryRows.map((d, i) => ( + + {d} + + ))} + + + ); +}; type APITokenPromptProps = { - step: number - config?: Config - configPath: string - onSubmit: (tokenMetadata: { - token: string - email: string - workspace: RudderAPI.Workspace - }) => void -} + step: number; + config?: Config; + configPath: string; + onSubmit: (tokenMetadata: { + token: string; + email: string; + workspace: RudderAPI.Workspace; + }) => void; +}; /** A prompt to walk a user through getting a new RudderStack API token. */ const APITokenPrompt: React.FC = ({ step, config, configPath, onSubmit }) => { - const [state, setState] = useState({ - token: '', - email: '', - canBeSet: true, - workspace: undefined as RudderAPI.Workspace | undefined, - isLoading: true, - isInvalid: false, - foundCachedToken: false, - tokenSubmitted: false, - tokenCursor: true, - }) - const { handleFatalError } = useContext(ErrorContext) - - useEffect(() => { - async function effect() { - try { - const tokens = await listTokens(config, configPath) - const method = await getTokenMethod(config, configPath) - const token = method === tokens.script.method ? tokens.script : tokens.file - - setState({ - ...state, - token: token.isValidToken ? token.token! : '', - email: token.email ? token.email! : '', - isInvalid: false, - workspace: token.workspace || state.workspace, - foundCachedToken: token.isValidToken, - isLoading: false, - // If the user already has a ruddertyper.yml with a valid token script, - // then let the user know that they can't overwrite it. - canBeSet: method !== tokens.script.method, - }) - } catch (error) { - handleFatalError(error as WrappedError) - } - } - - effect() - }, []) - - // Fired after a user enters a token. - const onConfirm = async () => { - // Validate whether the entered token is a valid RudderStack API token. - setState({ - ...state, - isLoading: true, - }) - - const result = await validateToken(state.token, state.email) - if (result.isValid) { - try { - await storeToken(state.token, state.email) - } catch (error) { - const err = error as APIError - handleFatalError( - wrapError( - 'Unable to save token to ~/.ruddertyper', - err, - `Failed due to an ${err.code} error (${err.errno}).` - ) - ) - return - } - - onSubmit({ token: state.token, email: state.email, workspace: result.workspace! }) - } else { - setState({ - ...state, - token: '', - email: '', - workspace: undefined, - isInvalid: true, - isLoading: false, - tokenSubmitted: false, - }) - } - } - - // Fired if a user confirms a cached token. - const onConfirmCachedToken = async (item: Item) => { - if (item.value === 'no') { - // Clear the selected token so they can enter their own. - setState({ - ...state, - foundCachedToken: false, - token: '', - email: '', - workspace: undefined, - isInvalid: false, - }) - } else { - // Otherwise submit this token. - await onConfirm() - } - } - - const setTokenSubmitted = () => { - setState({ - ...state, - tokenSubmitted: true, - }) - } - const setToken = (token: string) => { - setState({ - ...state, - token, - }) - } - - const setEmail = (email: string) => { - setState({ - ...state, - email, - }) - } - - const tips = [ - 'An API token is used to download Tracking Plans from Rudder.', - - Documentation on generating an API token can be found{' '} - here - . - , - ] - - if (state.foundCachedToken) { - tips.push( - - A cached token for {state.workspace!.name} is already in your environment. - - ) - } - - return ( -
- - {/* We found a token from a ruddertyper.yml token script. To let the user change token - * in this init command, we'd have to remove their token script. Instead, just tell - * the user this and don't let them change their token. */} - {!state.canBeSet && ( - - )} - {/* We found a token in a ~/.ruddertyper. Confirm that the user wants to use this token - * before continuing. */} - {state.canBeSet && state.foundCachedToken && ( - - )} - {/* We didn't find a token anywhere that they wanted to use, so just prompt the user for one. */} - {state.canBeSet && !state.foundCachedToken && ( - - - {figures.pointer}{' '} - - - {state.isInvalid && ( - - {figures.cross} Invalid Rudder API token. - - )} - - )} - - {state.tokenSubmitted && ( - - - - {figures.pointer}{' '} - - - - - )} -
- ) -} + const [state, setState] = useState({ + token: '', + email: '', + canBeSet: true, + workspace: undefined as RudderAPI.Workspace | undefined, + isLoading: true, + isInvalid: false, + foundCachedToken: false, + tokenSubmitted: false, + tokenCursor: true, + }); + const { handleFatalError } = useContext(ErrorContext); + + useEffect(() => { + async function effect() { + try { + const tokens = await listTokens(config, configPath); + const method = await getTokenMethod(config, configPath); + const token = method === tokens.script.method ? tokens.script : tokens.file; + + setState({ + ...state, + token: token.isValidToken ? token.token! : '', + email: token.email ? token.email! : '', + isInvalid: false, + workspace: token.workspace || state.workspace, + foundCachedToken: token.isValidToken, + isLoading: false, + // If the user already has a ruddertyper.yml with a valid token script, + // then let the user know that they can't overwrite it. + canBeSet: method !== tokens.script.method, + }); + } catch (error) { + handleFatalError(error as WrappedError); + } + } + + effect(); + }, []); + + // Fired after a user enters a token. + const onConfirm = async () => { + // Validate whether the entered token is a valid RudderStack API token. + setState({ + ...state, + isLoading: true, + }); + + const result = await validateToken(state.token, state.email); + if (result.isValid) { + try { + await storeToken(state.token, state.email); + } catch (error) { + const err = error as APIError; + handleFatalError( + wrapError( + 'Unable to save token to ~/.ruddertyper', + err, + `Failed due to an ${err.code} error (${err.errno}).`, + ), + ); + return; + } + + onSubmit({ token: state.token, email: state.email, workspace: result.workspace! }); + } else { + setState({ + ...state, + token: '', + email: '', + workspace: undefined, + isInvalid: true, + isLoading: false, + tokenSubmitted: false, + }); + } + }; + + // Fired if a user confirms a cached token. + const onConfirmCachedToken = async (item: Item) => { + if (item.value === 'no') { + // Clear the selected token so they can enter their own. + setState({ + ...state, + foundCachedToken: false, + token: '', + email: '', + workspace: undefined, + isInvalid: false, + }); + } else { + // Otherwise submit this token. + await onConfirm(); + } + }; + + const setTokenSubmitted = () => { + setState({ + ...state, + tokenSubmitted: true, + }); + }; + const setToken = (token: string) => { + setState({ + ...state, + token, + }); + }; + + const setEmail = (email: string) => { + setState({ + ...state, + email, + }); + }; + + const tips = [ + 'An API token is used to download Tracking Plans from Rudder.', + + Documentation on generating an API token can be found{' '} + + here + + . + , + ]; + + if (state.foundCachedToken) { + tips.push( + + A cached token for {state.workspace!.name} is already in your environment. + , + ); + } + + return ( +
+ + {/* We found a token from a ruddertyper.yml token script. To let the user change token + * in this init command, we'd have to remove their token script. Instead, just tell + * the user this and don't let them change their token. */} + {!state.canBeSet && ( + + )} + {/* We found a token in a ~/.ruddertyper. Confirm that the user wants to use this token + * before continuing. */} + {state.canBeSet && state.foundCachedToken && ( + + )} + {/* We didn't find a token anywhere that they wanted to use, so just prompt the user for one. */} + {state.canBeSet && !state.foundCachedToken && ( + + + {figures.pointer}{' '} + + + {state.isInvalid && ( + + {figures.cross} Invalid Rudder API token. + + )} + + )} + + {state.tokenSubmitted && ( + + + + {figures.pointer}{' '} + + + + + )} +
+ ); +}; type TrackingPlanPromptProps = { - step: number - token: string - email: string - trackingPlan?: RudderAPI.TrackingPlan - onSubmit: (trackingPlan: RudderAPI.TrackingPlan) => void -} + step: number; + token: string; + email: string; + trackingPlan?: RudderAPI.TrackingPlan; + onSubmit: (trackingPlan: RudderAPI.TrackingPlan) => void; +}; /** A prompt to identify which RudderStack Tracking Plan a user wants to use. */ // Needs an empty state — allows users to create a Tracking Plan, then a reload button to refetch const TrackingPlanPrompt: React.FC = ({ - step, - token, - email, - trackingPlan, - onSubmit, + step, + token, + email, + trackingPlan, + onSubmit, }) => { - const [trackingPlans, setTrackingPlans] = useState([]) - const [isLoading, setIsLoading] = useState(true) - const { handleFatalError } = useContext(ErrorContext) - - async function loadTrackingPlans() { - setIsLoading(true) - try { - setTrackingPlans(await fetchTrackingPlans({ token, email })) - setIsLoading(false) - } catch (error) { - const err = error as APIError - if (err.statusCode === 403) { - return handleFatalError( - wrapError( - 'Failed to authenticate with the RudderStack API', - err, - 'You may be using a malformed/invalid token or a legacy personal access token' - ) - ) - } else { - return handleFatalError( - wrapError( - 'Unable to fetch Tracking Plans', - err, - 'Check your internet connectivity and try again' - ) - ) - } - } - } - - useEffect(() => { - loadTrackingPlans() - }, []) - - const onSelect = (item: Item) => { - const trackingPlan = trackingPlans.find(tp => tp.name === item.value)! - onSubmit(trackingPlan) - } - - // Sort the Tracking Plan alphabetically by display name. - const choices = orderBy( - trackingPlans.map(tp => ({ - label: tp.display_name, - value: tp.name, - })), - 'label', - 'asc' - ) - let initialIndex = choices.findIndex(c => !!trackingPlan && c.value === trackingPlan.name) - initialIndex = initialIndex === -1 ? 0 : initialIndex - - const tips = [ - 'RudderTyper will generate a client from this Tracking Plan.', - - This Tracking Plan is saved locally in a plan.json file. - , - ] - - return ( - - {trackingPlans.length > 0 && ( - - )} - {trackingPlans.length === 0 && ( - - - - )} - - ) -} + const [trackingPlans, setTrackingPlans] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const { handleFatalError } = useContext(ErrorContext); + + async function loadTrackingPlans() { + setIsLoading(true); + try { + setTrackingPlans(await fetchTrackingPlans({ token, email })); + setIsLoading(false); + } catch (error) { + const err = error as APIError; + if (err.statusCode === 403) { + return handleFatalError( + wrapError( + 'Failed to authenticate with the RudderStack API', + err, + 'You may be using a malformed/invalid token or a legacy personal access token', + ), + ); + } else { + return handleFatalError( + wrapError( + 'Unable to fetch Tracking Plans', + err, + 'Check your internet connectivity and try again', + ), + ); + } + } + } + + useEffect(() => { + loadTrackingPlans(); + }, []); + + const onSelect = (item: Item) => { + const trackingPlan = trackingPlans.find(tp => getTrackingPlanName(tp) === item.value)!; + onSubmit(trackingPlan); + }; + + // Sort the Tracking Plan alphabetically by display name. + const choices = orderBy( + trackingPlans.map(tp => ({ + label: getTrackingPlanName(tp), + value: getTrackingPlanName(tp), + })), + 'label', + + 'asc', + ); + let initialIndex = choices.findIndex( + c => !!trackingPlan && c.value === getTrackingPlanName(trackingPlan), + ); + initialIndex = initialIndex === -1 ? 0 : initialIndex; + + const tips = [ + 'RudderTyper will generate a client from this Tracking Plan.', + + This Tracking Plan is saved locally in a plan.json file. + , + ]; + + return ( + + {trackingPlans.length > 0 && ( + + )} + {trackingPlans.length === 0 && ( + + + + )} + + ); +}; type SummaryPromptProps = { - step: number - sdk: SDK - language: Language - path: string - token: string - workspace: RudderAPI.Workspace - trackingPlan: RudderAPI.TrackingPlan - onConfirm: (config: Config) => void - onRestart: () => void -} + step: number; + sdk: SDK; + language: Language; + path: string; + token: string; + workspace: RudderAPI.Workspace; + trackingPlan: RudderAPI.TrackingPlan; + onConfirm: (config: Config) => void; + onRestart: () => void; +}; /** A prompt to confirm all of the configured settings with the user. */ const SummaryPrompt: React.FC = ({ - step, - sdk, - language, - path, - token, - workspace, - trackingPlan, - onConfirm, - onRestart, + step, + sdk, + language, + path, + token, + workspace, + trackingPlan, + onConfirm, + onRestart, }) => { - const [isLoading, setIsLoading] = useState(false) - const { handleFatalError } = useContext(ErrorContext) - - const onSelect = async (item: Item) => { - if (item.value === 'lgtm') { - // Write the updated ruddertyper.yml config. - setIsLoading(true) - - let client = ({ - sdk, - language, - } as unknown) as Options - // Default to ES5 syntax for analytics-node in JS, since node doesn't support things - // like ES6 modules. TypeScript transpiles for you, so we don't need it there. - // See https://node.green - if (sdk === SDK.NODE && language === Language.JAVASCRIPT) { - client = client as JavaScriptOptions - client.moduleTarget = 'CommonJS' - client.scriptTarget = 'ES5' - } - const tp = parseTrackingPlanName(trackingPlan.name) - try { - const config: Config = { - client, - trackingPlans: [ - { - name: trackingPlan.display_name, - id: tp.id, - workspaceSlug: tp.workspaceSlug, - path, - }, - ], - } - await setConfig(config) - setIsLoading(false) - onConfirm(config) - } catch (error) { - const err = error as APIError - handleFatalError( - wrapError( - 'Unable to write ruddertyper.yml', - err, - `Failed due to an ${err.code} error (${err.errno}).` - ) - ) - return - } - } else { - onRestart() - } - } - - const summaryRows = [ - { label: 'SDK', value: sdk }, - { label: 'Language', value: language }, - { label: 'Directory', value: path }, - { label: 'API Token', value: `${workspace.name} (${token.slice(0, 10)}...)` }, - { - label: 'Tracking Plan', - value: {trackingPlan.display_name}, - }, - ] - - const summary = ( - - {summaryRows.map(r => ( - - - {r.label}: - - {r.value} - - ))} - - ) - - return ( - - - - ) -} + const [isLoading, setIsLoading] = useState(false); + const { handleFatalError } = useContext(ErrorContext); + + const onSelect = async (item: Item) => { + if (item.value === 'lgtm') { + // Write the updated ruddertyper.yml config. + setIsLoading(true); + + let client = ({ + sdk, + language, + } as unknown) as Options; + // Default to ES5 syntax for analytics-node in JS, since node doesn't support things + // like ES6 modules. TypeScript transpiles for you, so we don't need it there. + // See https://node.green + if (sdk === SDK.NODE && language === Language.JAVASCRIPT) { + client = client as JavaScriptOptions; + client.moduleTarget = 'CommonJS'; + client.scriptTarget = 'ES5'; + } + const tp = trackingPlan.creationType + ? { id: trackingPlan.id, workspaceSlug: trackingPlan.workspaceId, APIVersion: 'v2' } + : parseTrackingPlanName(trackingPlan.name); + try { + const config: Config = { + client, + trackingPlans: [ + { + name: getTrackingPlanName(trackingPlan), + id: tp.id, + workspaceSlug: tp.workspaceSlug, + path, + APIVersion: tp.APIVersion, + }, + ], + }; + await setConfig(config); + setIsLoading(false); + onConfirm(config); + } catch (error) { + const err = error as APIError; + handleFatalError( + wrapError( + 'Unable to write ruddertyper.yml', + err, + `Failed due to an ${err.code} error (${err.errno}).`, + ), + ); + return; + } + } else { + onRestart(); + } + }; + + const summaryRows = [ + { label: 'SDK', value: sdk }, + { label: 'Language', value: language }, + { label: 'Directory', value: path }, + { label: 'API Token', value: `${workspace.name} (${token.slice(0, 10)}...)` }, + { label: 'API Version', value: trackingPlan.creationType ? 'v2' : 'v1' }, + { + label: 'Tracking Plan', + value: {getTrackingPlanName(trackingPlan)}, + }, + ]; + + const summary = ( + + {summaryRows.map(r => ( + + + {r.label}: + + {r.value} + + ))} + + ); + + return ( + + + + ); +}; type StepProps = { - name: string - step?: number - isLoading?: boolean - description?: JSX.Element - tips?: (string | JSX.Element)[] -} + name: string; + step?: number; + isLoading?: boolean; + description?: JSX.Element; + tips?: (string | JSX.Element)[]; +}; const Step: React.FC = ({ - step, - name, - isLoading = false, - description, - tips, - children, + step, + name, + isLoading = false, + description, + tips, + children, }) => { - const { debug } = useContext(DebugContext) - - return ( - - - - {name} - - {step && ( - - [{step}/6] - - )} - - - {tips && - tips.map((t, i) => ( - - {figures.arrowRight} {t} - - ))} - {description} - - {isLoading && ( - - {!debug && ( - <> - {' '} - - )} - Loading... - - )} - {!isLoading && children} - - - - ) -} + const { debug } = useContext(DebugContext); + + return ( + + + + {name} + + {step && ( + + [{step}/6] + + )} + + + {tips && + tips.map((t, i) => ( + + {figures.arrowRight} {t} + + ))} + {description} + + {isLoading && ( + + {!debug && ( + <> + {' '} + + )} + Loading... + + )} + {!isLoading && children} + + + + ); +}; diff --git a/src/cli/commands/token.tsx b/src/cli/commands/token.tsx index 7454562d..b37beeb9 100644 --- a/src/cli/commands/token.tsx +++ b/src/cli/commands/token.tsx @@ -1,75 +1,75 @@ -import React, { useState, useEffect, useContext } from 'react' -import { Box, Color, Text, useApp } from 'ink' -import Link from 'ink-link' -import Spinner from 'ink-spinner' -import { listTokens, ListTokensOutput, getTokenMethod, TokenMetadata } from '../config' -import { StandardProps } from '../index' -import { ErrorContext } from './error' +import React, { useState, useEffect, useContext } from 'react'; +import { Box, Color, Text, useApp } from 'ink'; +import Link from 'ink-link'; +import Spinner from 'ink-spinner'; +import { listTokens, ListTokensOutput, getTokenMethod, TokenMetadata } from '../config'; +import { StandardProps } from '../index'; +import { ErrorContext } from './error'; export const Token: React.FC = props => { - const [isLoading, setIsLoading] = useState(true) - const [method, setMethod] = useState() - const [tokens, setTokens] = useState() - const { handleFatalError } = useContext(ErrorContext) - const { exit } = useApp() + const [isLoading, setIsLoading] = useState(true); + const [method, setMethod] = useState(); + const [tokens, setTokens] = useState(); + const { handleFatalError } = useContext(ErrorContext); + const { exit } = useApp(); - useEffect(() => { - async function effect() { - setMethod(await getTokenMethod(props.config, props.configPath)) - setTokens(await listTokens(props.config, props.configPath)) - setIsLoading(false) - exit() - } + useEffect(() => { + async function effect() { + setMethod(await getTokenMethod(props.config, props.configPath)); + setTokens(await listTokens(props.config, props.configPath)); + setIsLoading(false); + exit(); + } - effect().catch(handleFatalError) - }, []) + effect().catch(handleFatalError); + }, []); - if (isLoading) { - return ( - - Loading... - - ) - } + if (isLoading) { + return ( + + Loading... + + ); + } - return ( - - - - - - - ) -} + return ( + + + + + + + ); +}; type TokenRowProps = { - tokenMetadata?: TokenMetadata - method?: string - name: string -} + tokenMetadata?: TokenMetadata; + method?: string; + name: string; +}; const TokenRow: React.FC = ({ tokenMetadata, method, name }) => { - const isSelected = tokenMetadata && method === tokenMetadata.method + const isSelected = tokenMetadata && method === tokenMetadata.method; - return ( - - - {name}: - - {tokenMetadata && tokenMetadata.token - ? `${tokenMetadata.token.slice(0, 10)}...` - : '(None)'} - - {tokenMetadata && !!tokenMetadata.token && !tokenMetadata.isValidToken ? ( - - (invalid token) - - ) : ( - - {tokenMetadata && tokenMetadata.workspace ? tokenMetadata.workspace.name : ''} - - )} - - - ) -} + return ( + + + {name}: + + {tokenMetadata && tokenMetadata.token + ? `${tokenMetadata.token.slice(0, 10)}...` + : '(None)'} + + {tokenMetadata && !!tokenMetadata.token && !tokenMetadata.isValidToken ? ( + + (invalid token) + + ) : ( + + {tokenMetadata && tokenMetadata.workspace ? tokenMetadata.workspace.name : ''} + + )} + + + ); +}; diff --git a/src/cli/commands/version.tsx b/src/cli/commands/version.tsx index 648d96af..aa8905e1 100644 --- a/src/cli/commands/version.tsx +++ b/src/cli/commands/version.tsx @@ -1,60 +1,60 @@ -import React, { useState, useEffect, useContext } from 'react' -import { Box, Color, useApp } from 'ink' -import { version as ruddertyperVersion } from '../../../package.json' -import latest from 'latest-version' -import { StandardProps } from '../index' -import { ErrorContext, WrappedError } from './error' -import semver from 'semver' +import React, { useState, useEffect, useContext } from 'react'; +import { Box, Color, useApp } from 'ink'; +import { version as ruddertyperVersion } from '../../../package.json'; +import latest from 'latest-version'; +import { StandardProps } from '../index'; +import { ErrorContext, WrappedError } from './error'; +import semver from 'semver'; export const Version: React.FC = () => { - const [isLoading, setIsLoading] = useState(true) - const [latestVersion, setLatestVersion] = useState('') - const { handleError } = useContext(ErrorContext) - const { exit } = useApp() - - useEffect(() => { - async function effect() { - try { - let options: latest.Options = {} - - // If the user is on a pre-release, check if there's a new pre-release. - // Otherwise, only compare against stable versions. - const prerelease = semver.prerelease(ruddertyperVersion) - if (prerelease && prerelease.length > 0) { - options = { version: 'next' } - } - - const latestVersion = await latest('rudder-typer', options) - setLatestVersion(latestVersion) - } catch (error) { - // If we can't access NPM, then ignore this version check. - handleError(error as WrappedError) - } - setIsLoading(false) - exit() - } - - effect() - }, []) - - const isLatest = isLoading || latestVersion === '' || latestVersion === ruddertyperVersion - const newVersionText = isLoading - ? '(checking for newer versions...)' - : !isLatest - ? `(new! ${latestVersion})` - : '' - - return ( - - Version: - - {ruddertyperVersion}{' '} - - - {newVersionText} - - - ) -} - -Version.displayName = 'Version' + const [isLoading, setIsLoading] = useState(true); + const [latestVersion, setLatestVersion] = useState(''); + const { handleError } = useContext(ErrorContext); + const { exit } = useApp(); + + useEffect(() => { + async function effect() { + try { + let options: latest.Options = {}; + + // If the user is on a pre-release, check if there's a new pre-release. + // Otherwise, only compare against stable versions. + const prerelease = semver.prerelease(ruddertyperVersion); + if (prerelease && prerelease.length > 0) { + options = { version: 'next' }; + } + + const latestVersion = await latest('rudder-typer', options); + setLatestVersion(latestVersion); + } catch (error) { + // If we can't access NPM, then ignore this version check. + handleError(error as WrappedError); + } + setIsLoading(false); + exit(); + } + + effect(); + }, []); + + const isLatest = isLoading || latestVersion === '' || latestVersion === ruddertyperVersion; + const newVersionText = isLoading + ? '(checking for newer versions...)' + : !isLatest + ? `(new! ${latestVersion})` + : ''; + + return ( + + Version: + + {ruddertyperVersion}{' '} + + + {newVersionText} + + + ); +}; + +Version.displayName = 'Version'; diff --git a/src/cli/config/config.ts b/src/cli/config/config.ts index 4bb428e8..41d91be6 100644 --- a/src/cli/config/config.ts +++ b/src/cli/config/config.ts @@ -1,21 +1,21 @@ -import * as fs from 'fs' -import { promisify } from 'util' -import { resolve, dirname } from 'path' -import * as yaml from 'js-yaml' -import { generateFromTemplate } from '../../templates' -import { homedir } from 'os' -import { Config, validateConfig } from './schema' -import { validateToken, RudderAPI } from '../api' -import { wrapError } from '../commands/error' -import { runScript, Scripts } from './scripts' -import { APIError } from '../types' - -const readFile = promisify(fs.readFile) -const writeFile = promisify(fs.writeFile) -const exists = promisify(fs.exists) -const mkdir = promisify(fs.mkdir) - -export const CONFIG_NAME = 'ruddertyper.yml' +import * as fs from 'fs'; +import { promisify } from 'util'; +import { resolve, dirname } from 'path'; +import * as yaml from 'js-yaml'; +import { generateFromTemplate } from '../../templates'; +import { homedir } from 'os'; +import { Config, validateConfig } from './schema'; +import { validateToken, RudderAPI } from '../api'; +import { wrapError } from '../commands/error'; +import { runScript, Scripts } from './scripts'; +import { APIError } from '../types'; + +const readFile = promisify(fs.readFile); +const writeFile = promisify(fs.writeFile); +const exists = promisify(fs.exists); +const mkdir = promisify(fs.mkdir); + +export const CONFIG_NAME = 'ruddertyper.yml'; // getConfig looks for, and reads, a ruddertyper.yml configuration file. // If it does not exist, it will return undefined. If the configuration @@ -23,67 +23,67 @@ export const CONFIG_NAME = 'ruddertyper.yml' // Note: path is relative to the directory where the ruddertyper command // was run. export async function getConfig(path = './'): Promise { - // Check if a ruddertyper.yml exists. - const configPath = await getPath(path) - if (!(await exists(configPath))) { - return undefined - } - - // If so, read it's contents. - let file - try { - file = await readFile(configPath, { - encoding: 'utf-8', - }) - } catch (error) { - const err = error as APIError - throw wrapError( - 'Unable to open ruddertyper.yml', - err, - `Failed due to an ${err.code} error (${err.errno}).`, - configPath - ) - } - - const rawConfig = yaml.safeLoad(file) - - return validateConfig(rawConfig as Config) + // Check if a ruddertyper.yml exists. + const configPath = await getPath(path); + if (!(await exists(configPath))) { + return undefined; + } + + // If so, read it's contents. + let file; + try { + file = await readFile(configPath, { + encoding: 'utf-8', + }); + } catch (error) { + const err = error as APIError; + throw wrapError( + 'Unable to open ruddertyper.yml', + err, + `Failed due to an ${err.code} error (${err.errno}).`, + configPath, + ); + } + + const rawConfig = yaml.safeLoad(file); + + return validateConfig(rawConfig as Config); } // setConfig writes a config out to a ruddertyper.yml file. // Note path is relative to the directory where the ruddertyper command // was run. export async function setConfig(config: Config, path = './'): Promise { - const file = await generateFromTemplate('cli/config/ruddertyper.yml.hbs', config, false) + const file = await generateFromTemplate('cli/config/ruddertyper.yml.hbs', config, false); - await writeFile(await getPath(path), file) + await writeFile(await getPath(path), file); } // resolveRelativePath resolves a relative path from the directory of the `ruddertyper.yml` config // file. It supports file and directory paths. export function resolveRelativePath( - configPath: string | undefined, - path: string, - ...otherPaths: string[] + configPath: string | undefined, + path: string, + ...otherPaths: string[] ): string { - // Resolve the path based on the optional --config flag. - return configPath - ? resolve(configPath.replace(/ruddertyper\.yml$/, ''), path, ...otherPaths) - : resolve(path, ...otherPaths) + // Resolve the path based on the optional --config flag. + return configPath + ? resolve(configPath.replace(/ruddertyper\.yml$/, ''), path, ...otherPaths) + : resolve(path, ...otherPaths); } export async function verifyDirectoryExists( - path: string, - type: 'directory' | 'file' = 'directory' + path: string, + type: 'directory' | 'file' = 'directory', ): Promise { - // If this is a file, we need to verify it's parent directory exists. - // If it is a directory, then we need to verify the directory itself exists. - const dirPath = type === 'directory' ? path : dirname(path) - if (!(await exists(dirPath))) { - await mkdir(dirPath, { - recursive: true, - }) - } + // If this is a file, we need to verify it's parent directory exists. + // If it is a directory, then we need to verify the directory itself exists. + const dirPath = type === 'directory' ? path : dirname(path); + if (!(await exists(dirPath))) { + await mkdir(dirPath, { + recursive: true, + }); + } } // getToken uses a Config to fetch a RudderStack API token. It will search for it in this order: @@ -91,126 +91,126 @@ export async function verifyDirectoryExists( // 2. cat ~/.ruddertyper // Returns undefined if no token can be found. export async function getToken( - cfg: Partial | undefined, - configPath: string + cfg: Partial | undefined, + configPath: string, ): Promise { - const token = await getTokenMetadata(cfg, configPath) - return token ? token.token : undefined + const token = await getTokenMetadata(cfg, configPath); + return token ? token.token : undefined; } export async function getEmail( - cfg: Partial | undefined, - configPath: string + cfg: Partial | undefined, + configPath: string, ): Promise { - const token = await getTokenMetadata(cfg, configPath) - return token ? token.email : undefined + const token = await getTokenMetadata(cfg, configPath); + return token ? token.email : undefined; } export async function getTokenMethod( - cfg: Partial | undefined, - configPath: string + cfg: Partial | undefined, + configPath: string, ): Promise { - const token = await getTokenMetadata(cfg, configPath) - return token ? token.method : undefined + const token = await getTokenMetadata(cfg, configPath); + return token ? token.method : undefined; } async function getTokenMetadata( - cfg: Partial | undefined, - configPath: string + cfg: Partial | undefined, + configPath: string, ): Promise { - const tokens = await listTokens(cfg, configPath) - const resolutionOrder = [tokens.script, tokens.file] - for (const metadata of resolutionOrder) { - if (metadata.isValidToken) { - return metadata - } - } - - return undefined + const tokens = await listTokens(cfg, configPath); + const resolutionOrder = [tokens.script, tokens.file]; + for (const metadata of resolutionOrder) { + if (metadata.isValidToken) { + return metadata; + } + } + + return undefined; } export type ListTokensOutput = { - script: TokenMetadata - file: TokenMetadata -} + script: TokenMetadata; + file: TokenMetadata; +}; export type TokenMetadata = { - token?: string - email?: string - method: 'script' | 'file' - isValidToken: boolean - workspace?: RudderAPI.Workspace -} + token?: string; + email?: string; + method: 'script' | 'file'; + isValidToken: boolean; + workspace?: RudderAPI.Workspace; +}; // Only resolve token and email scripts once per CLI invocation. // Maps a token script -> output, if any -const tokenScriptCache: Record = {} -const emailScriptCache: Record = {} +const tokenScriptCache: Record = {}; +const emailScriptCache: Record = {}; export async function listTokens( - cfg: Partial | undefined, - configPath: string + cfg: Partial | undefined, + configPath: string, ): Promise { - const output: ListTokensOutput = { - script: { method: 'script', isValidToken: false }, - file: { method: 'file', isValidToken: false }, - } - - // Attempt to read a token and email from the ~/.ruddertyper token file. - // Token and email are stored here during the `init` flow, if a user generates a token. - try { - const path = resolve(homedir(), '.ruddertyper') - const cachedTokenData = await readFile(path, { - encoding: 'utf-8', - }) - output.file.token = cachedTokenData.split(',')[0].trim() - output.file.email = cachedTokenData.split(',')[1].trim() - } catch (error) { - // Ignore errors if ~/.ruddertyper doesn't exist - } - - // Attempt to read a token and email by executing their respective script from the ruddertyper.yml config file. - // Handle token script errors gracefully, f.e., in CI where you don't need it. - if (cfg && cfg.scripts && cfg.scripts.token && cfg.scripts.email) { - const tokenScript = cfg.scripts.token - const emailScript = cfg.scripts.email - // Since we don't know if this token script has side effects, cache (in-memory) the result - // s.t. we only execute it once per CLI invocation. - if (!tokenScriptCache[tokenScript]) { - const stdout = await runScript(tokenScript, configPath, Scripts.Token) - if (!!stdout) { - tokenScriptCache[tokenScript] = stdout.trim() - } - } - if (!emailScriptCache[emailScript]) { - const stdout = await runScript(emailScript, configPath, Scripts.Email) - if (!!stdout) { - emailScriptCache[emailScript] = stdout.trim() - } - } - - output.script.token = tokenScriptCache[tokenScript] - output.script.email = emailScriptCache[emailScript] - } - - // Validate whether any of these tokens are valid RudderStack API tokens. - for (const metadata of Object.values(output)) { - const result = await validateToken(metadata.token, metadata.email) - metadata.isValidToken = result.isValid - metadata.workspace = result.workspace - } - - return output + const output: ListTokensOutput = { + script: { method: 'script', isValidToken: false }, + file: { method: 'file', isValidToken: false }, + }; + + // Attempt to read a token and email from the ~/.ruddertyper token file. + // Token and email are stored here during the `init` flow, if a user generates a token. + try { + const path = resolve(homedir(), '.ruddertyper'); + const cachedTokenData = await readFile(path, { + encoding: 'utf-8', + }); + output.file.token = cachedTokenData.split(',')[0].trim(); + output.file.email = cachedTokenData.split(',')[1].trim(); + } catch (error) { + // Ignore errors if ~/.ruddertyper doesn't exist + } + + // Attempt to read a token and email by executing their respective script from the ruddertyper.yml config file. + // Handle token script errors gracefully, f.e., in CI where you don't need it. + if (cfg && cfg.scripts && cfg.scripts.token && cfg.scripts.email) { + const tokenScript = cfg.scripts.token; + const emailScript = cfg.scripts.email; + // Since we don't know if this token script has side effects, cache (in-memory) the result + // s.t. we only execute it once per CLI invocation. + if (!tokenScriptCache[tokenScript]) { + const stdout = await runScript(tokenScript, configPath, Scripts.Token); + if (!!stdout) { + tokenScriptCache[tokenScript] = stdout.trim(); + } + } + if (!emailScriptCache[emailScript]) { + const stdout = await runScript(emailScript, configPath, Scripts.Email); + if (!!stdout) { + emailScriptCache[emailScript] = stdout.trim(); + } + } + + output.script.token = tokenScriptCache[tokenScript]; + output.script.email = emailScriptCache[emailScript]; + } + + // Validate whether any of these tokens are valid RudderStack API tokens. + for (const metadata of Object.values(output)) { + const result = await validateToken(metadata.token, metadata.email); + metadata.isValidToken = result.isValid; + metadata.workspace = result.workspace; + } + + return output; } // storeToken writes a token to ~/.ruddertyper. export async function storeToken(token: string, email: string): Promise { - const path = resolve(homedir(), '.ruddertyper') - return writeFile(path, token + ',' + email, 'utf-8') + const path = resolve(homedir(), '.ruddertyper'); + return writeFile(path, token + ',' + email, 'utf-8'); } async function getPath(path: string): Promise { - path = path.replace(/ruddertyper\.yml$/, '') - // TODO: recursively move back folders until you find it, ala package.json - return resolve(path, CONFIG_NAME) + path = path.replace(/ruddertyper\.yml$/, ''); + // TODO: recursively move back folders until you find it, ala package.json + return resolve(path, CONFIG_NAME); } diff --git a/src/cli/config/index.ts b/src/cli/config/index.ts index d3c99939..0ce48bce 100644 --- a/src/cli/config/index.ts +++ b/src/cli/config/index.ts @@ -1,14 +1,14 @@ export { - getConfig, - setConfig, - resolveRelativePath, - verifyDirectoryExists, - getToken, - getTokenMethod, - listTokens, - ListTokensOutput, - TokenMetadata, - storeToken, -} from './config' -export { Config, TrackingPlanConfig } from './schema' -export { runScript, Scripts } from './scripts' + getConfig, + setConfig, + resolveRelativePath, + verifyDirectoryExists, + getToken, + getTokenMethod, + listTokens, + ListTokensOutput, + TokenMetadata, + storeToken, +} from './config'; +export { Config, TrackingPlanConfig } from './schema'; +export { runScript, Scripts } from './scripts'; diff --git a/src/cli/config/ruddertyper.yml.hbs b/src/cli/config/ruddertyper.yml.hbs index 96b9a055..b8555829 100644 --- a/src/cli/config/ruddertyper.yml.hbs +++ b/src/cli/config/ruddertyper.yml.hbs @@ -18,5 +18,5 @@ trackingPlans: - id: {{id}} workspaceSlug: {{workspaceSlug}} path: {{path}} - + APIVersion: {{APIVersion}} {{/each}} diff --git a/src/cli/config/schema.ts b/src/cli/config/schema.ts index 414d522d..9e07f335 100644 --- a/src/cli/config/schema.ts +++ b/src/cli/config/schema.ts @@ -1,50 +1,54 @@ -import { Options } from 'src/generators/options' -import Joi from '@hapi/joi' +import { Options } from 'src/generators/options'; +import Joi from '@hapi/joi'; /** * A config, stored in a ruddertyper.yml file. * If you update this inferface, make sure to also update the Joi schema (ConfigSchema) below. */ export type Config = { - /** A set of optional shell commands to customize ruddertyper's behavior. */ - scripts?: { - /** - * An optional shell command that must produce a RudderStack API token as its only output. - */ - token?: string - /** - * The Email id of the owner of the token - */ - email?: string - /** - * An optional shell command executed after ruddertyper updates/builds clients - * which can be used for things like applying automatic formatting to generated files. - */ - after?: string - } - /** Metadata on how to configure a client (language, SDK, module-type, etc.). */ - client: Options - /** Which Tracking Plans to sync locally and generate clients for. */ - trackingPlans: TrackingPlanConfig[] -} + /** A set of optional shell commands to customize ruddertyper's behavior. */ + scripts?: { + /** + * An optional shell command that must produce a RudderStack API token as its only output. + */ + token?: string; + /** + * The Email id of the owner of the token + */ + email?: string; + /** + * An optional shell command executed after ruddertyper updates/builds clients + * which can be used for things like applying automatic formatting to generated files. + */ + after?: string; + }; + /** Metadata on how to configure a client (language, SDK, module-type, etc.). */ + client: Options; + /** Which Tracking Plans to sync locally and generate clients for. */ + trackingPlans: TrackingPlanConfig[]; +}; /** Metadata on a specific Tracking Plan to generate a client for. */ export type TrackingPlanConfig = { - /** - * The name of the Tracking Plan. Only set during the `init` step, so it - * can be added as a comment in the generated `ruddertyper.yml`. - */ - name?: string - /** The id of the Tracking Plan to generate a client for. */ - id: string - /** The slug of the RudderStack workspace that owns this Tracking Plan. */ - workspaceSlug: string - /** - * A directory path relative to this ruddertyper.yml file, specifying where - * this Tracking Plan's client should be output. - */ - path: string -} + /** + * The name of the Tracking Plan. Only set during the `init` step, so it + * can be added as a comment in the generated `ruddertyper.yml`. + */ + name?: string; + /** The id of the Tracking Plan to generate a client for. */ + id: string; + /** The slug of the RudderStack workspace that owns this Tracking Plan. */ + workspaceSlug: string; + /** + * A directory path relative to this ruddertyper.yml file, specifying where + * this Tracking Plan's client should be output. + */ + path: string; + /** + * The API version to use for the tracking plan + */ + APIVersion: string; +}; // Ignore Prettier here, since otherwise prettier adds quite a bit of spacing // that makes this schema too long+verbose. @@ -81,20 +85,21 @@ const ConfigSchema = Joi.object().required().keys({ id: Joi.string().required().min(1), workspaceSlug: Joi.string().required().min(1), path: Joi.string().required().min(1), + APIVersion: Joi.string().required().min(1), }) ), }) export const validateConfig = (rawConfig: Record): Config => { - // Validate the provided configuration file using our Joi schema. - const result = Joi.validate(rawConfig, ConfigSchema, { - abortEarly: false, - convert: false, - }) - if (!!result.error) { - throw new Error(result.error.annotate()) - } + // Validate the provided configuration file using our Joi schema. + const result = Joi.validate(rawConfig, ConfigSchema, { + abortEarly: false, + convert: false, + }); + if (!!result.error) { + throw new Error(result.error.annotate()); + } - // We can safely type cast the config, now that is has been validated. - return (rawConfig as unknown) as Config -} + // We can safely type cast the config, now that is has been validated. + return (rawConfig as unknown) as Config; +}; diff --git a/src/cli/config/scripts.ts b/src/cli/config/scripts.ts index 88a80d6b..43442082 100644 --- a/src/cli/config/scripts.ts +++ b/src/cli/config/scripts.ts @@ -1,34 +1,34 @@ -import * as childProcess from 'child_process' -import { promisify } from 'util' -import { wrapError } from '../commands/error' +import * as childProcess from 'child_process'; +import { promisify } from 'util'; +import { wrapError } from '../commands/error'; -const exec = promisify(childProcess.exec) +const exec = promisify(childProcess.exec); export enum Scripts { - After = 'After', - Token = 'Token', - Email = 'Email', + After = 'After', + Token = 'Token', + Email = 'Email', } -const EXEC_TIMEOUT = 5000 // ms +const EXEC_TIMEOUT = 5000; // ms export async function runScript( - script: string, - configPath: string, - type: Scripts + script: string, + configPath: string, + type: Scripts, ): Promise { - const scriptWithCD = `cd ${configPath}; ${script}` - const { stdout } = await exec(scriptWithCD, { timeout: EXEC_TIMEOUT }).catch(err => { - const { stderr = '' } = err - const firstStdErrLine = stderr.split('\n')[0] - // This child process will be SIGTERM-ed if it times out. - throw wrapError( - err.signal === 'SIGTERM' ? `${type} script timed out` : `${type} script failed`, - err, - `Tried running: '${script}'`, - firstStdErrLine - ) - }) + const scriptWithCD = `cd ${configPath}; ${script}`; + const { stdout } = await exec(scriptWithCD, { timeout: EXEC_TIMEOUT }).catch(err => { + const { stderr = '' } = err; + const firstStdErrLine = stderr.split('\n')[0]; + // This child process will be SIGTERM-ed if it times out. + throw wrapError( + err.signal === 'SIGTERM' ? `${type} script timed out` : `${type} script failed`, + err, + `Tried running: '${script}'`, + firstStdErrLine, + ); + }); - return stdout + return stdout; } diff --git a/src/cli/index.tsx b/src/cli/index.tsx index 94ec4ceb..45590462 100644 --- a/src/cli/index.tsx +++ b/src/cli/index.tsx @@ -2,271 +2,272 @@ // Default to production, so that React error messages are not shown. // Note: this must happen before we import React. -process.env.NODE_ENV = process.env.NODE_ENV || 'production' +process.env.NODE_ENV = process.env.NODE_ENV || 'production'; -import React, { createContext } from 'react' -import { render } from 'ink' -import { Token, Version, Build, Help, Init, ErrorBoundary } from './commands' -import { Config, getConfig, getTokenMethod } from './config' -import { machineId } from 'node-machine-id' -import { version } from '../../package.json' -import { loadTrackingPlan } from './api' -import yargs from 'yargs' +import React, { createContext } from 'react'; +import { render } from 'ink'; +import { Token, Version, Build, Help, Init, ErrorBoundary } from './commands'; +import { Config, getConfig, getTokenMethod } from './config'; +import { machineId } from 'node-machine-id'; +import { version } from '../../package.json'; +import { loadTrackingPlan } from './api'; +import yargs from 'yargs'; +import { getTrackingPlanName } from './api/trackingplans'; export type StandardProps = AnalyticsProps & { - configPath: string - config?: Config -} + configPath: string; + config?: Config; +}; export type AnalyticsProps = { - analyticsProps: AsyncReturnType - anonymousId: string -} + analyticsProps: AsyncReturnType; + anonymousId: string; +}; export type CLIArguments = { - /** An optional path to a ruddertyper.yml (or directory with a ruddertyper.yml). **/ - config: string - /** An optional (hidden) flag for enabling Ink debug mode. */ - debug: boolean - /** Standard --version flag to print the version of this CLI. */ - version: boolean - /** Standard -v flag to print the version of this CLI. */ - v: boolean - /** Standard --help flag to print help on a command. */ - help: boolean - /** Standard -h flag to print help on a command. */ - h: boolean -} + /** An optional path to a ruddertyper.yml (or directory with a ruddertyper.yml). **/ + config: string; + /** An optional (hidden) flag for enabling Ink debug mode. */ + debug: boolean; + /** Standard --version flag to print the version of this CLI. */ + version: boolean; + /** Standard -v flag to print the version of this CLI. */ + v: boolean; + /** Standard --help flag to print help on a command. */ + help: boolean; + /** Standard -h flag to print help on a command. */ + h: boolean; +}; const commandDefaults: { - builder: Record + builder: Record; } = { - builder: { - config: { - type: 'string', - default: './', - }, - version: { - type: 'boolean', - default: false, - }, - v: { - type: 'boolean', - default: false, - }, - help: { - type: 'boolean', - default: false, - }, - h: { - type: 'boolean', - default: false, - }, - debug: { - type: 'boolean', - default: false, - }, - }, -} + builder: { + config: { + type: 'string', + default: './', + }, + version: { + type: 'boolean', + default: false, + }, + v: { + type: 'boolean', + default: false, + }, + help: { + type: 'boolean', + default: false, + }, + h: { + type: 'boolean', + default: false, + }, + debug: { + type: 'boolean', + default: false, + }, + }, +}; // The `.argv` below will boot a Yargs CLI. yargs - .command({ - ...commandDefaults, - command: ['init', 'initialize', 'quickstart'], - handler: toYargsHandler(Init, {}), - }) - .command({ - ...commandDefaults, - command: ['update', 'u', '*'], - handler: toYargsHandler(Build, { production: false, update: true }, { validateDefault: true }), - }) - .command({ - ...commandDefaults, - command: ['build', 'b', 'd', 'dev', 'development'], - handler: toYargsHandler(Build, { production: false, update: false }), - }) - .command({ - ...commandDefaults, - command: ['prod', 'p', 'production'], - handler: toYargsHandler(Build, { production: true, update: false }), - }) - .command({ - ...commandDefaults, - command: ['token', 'tokens', 't'], - handler: toYargsHandler(Token, {}), - }) - .command({ - ...commandDefaults, - command: 'version', - handler: toYargsHandler(Version, {}), - }) - .command({ - ...commandDefaults, - command: 'help', - handler: toYargsHandler(Help, {}), - }) - .strict(true) - // We override help + version ourselves. - .help(false) - .showHelpOnFail(false) - .version(false).argv + .command({ + ...commandDefaults, + command: ['init', 'initialize', 'quickstart'], + handler: toYargsHandler(Init, {}), + }) + .command({ + ...commandDefaults, + command: ['update', 'u', '*'], + handler: toYargsHandler(Build, { production: false, update: true }, { validateDefault: true }), + }) + .command({ + ...commandDefaults, + command: ['build', 'b', 'd', 'dev', 'development'], + handler: toYargsHandler(Build, { production: false, update: false }), + }) + .command({ + ...commandDefaults, + command: ['prod', 'p', 'production'], + handler: toYargsHandler(Build, { production: true, update: false }), + }) + .command({ + ...commandDefaults, + command: ['token', 'tokens', 't'], + handler: toYargsHandler(Token, {}), + }) + .command({ + ...commandDefaults, + command: 'version', + handler: toYargsHandler(Version, {}), + }) + .command({ + ...commandDefaults, + command: 'help', + handler: toYargsHandler(Help, {}), + }) + .strict(true) + // We override help + version ourselves. + .help(false) + .showHelpOnFail(false) + .version(false).argv; type DebugContextProps = { - /** Whether or not debug mode is enabled. */ - debug: boolean -} -export const DebugContext = createContext({ debug: false }) + /** Whether or not debug mode is enabled. */ + debug: boolean; +}; +export const DebugContext = createContext({ debug: false }); function toYargsHandler

( - Command: React.FC, - props: P, - cliOptions?: { validateDefault?: boolean } + Command: React.FC, + props: P, + cliOptions?: { validateDefault?: boolean }, ) { - // Return a closure which yargs will execute if this command is run. - return async (args: yargs.Arguments) => { - let anonymousId = 'unknown' - try { - anonymousId = await getAnonymousId() - } catch (error) {} + // Return a closure which yargs will execute if this command is run. + return async (args: yargs.Arguments) => { + let anonymousId = 'unknown'; + try { + anonymousId = await getAnonymousId(); + } catch (error) {} - try { - // The '*' command is a catch-all. We want to fail the CLI if an unknown command is - // supplied ('yarn rudder-typer footothebar'), instead of just running the default command. - const isValidCommand = - !cliOptions || - !cliOptions.validateDefault || - args._.length === 0 || - ['update', 'u'].includes(args._[0] as string) + try { + // The '*' command is a catch-all. We want to fail the CLI if an unknown command is + // supplied ('yarn rudder-typer footothebar'), instead of just running the default command. + const isValidCommand = + !cliOptions || + !cliOptions.validateDefault || + args._.length === 0 || + ['update', 'u'].includes(args._[0] as string); - // Attempt to read a config, if one is available. - const cfg = await getConfig(args.config) + // Attempt to read a config, if one is available. + const cfg = await getConfig(args.config); - const analyticsProps = await rudderTyperLibraryProperties(args, cfg) + const analyticsProps = await rudderTyperLibraryProperties(args, cfg); - // Figure out which component to render. - let Component = Command - // Certain flags (--version, --help) will overide whatever command was provided. - if (!!args.version || !!args.v || Command.displayName === Version.displayName) { - // We override the --version flag from yargs with our own output. If it was supplied, print - // the `version` component instead. - Component = Version as typeof Command - } else if ( - !isValidCommand || - !!args.help || - !!args.h || - args._.includes('help') || - Command.displayName === Help.displayName - ) { - // Same goes for the --help flag. - Component = Help as typeof Command - } + // Figure out which component to render. + let Component = Command; + // Certain flags (--version, --help) will overide whatever command was provided. + if (!!args.version || !!args.v || Command.displayName === Version.displayName) { + // We override the --version flag from yargs with our own output. If it was supplied, print + // the `version` component instead. + Component = Version as typeof Command; + } else if ( + !isValidCommand || + !!args.help || + !!args.h || + args._.includes('help') || + Command.displayName === Help.displayName + ) { + // Same goes for the --help flag. + Component = Help as typeof Command; + } - // 🌟Render the command. - try { - const { waitUntilExit } = render( - - - - - , - { debug: !!args.debug } - ) - await waitUntilExit() - } catch (err) { - // Errors are handled/reported in ErrorBoundary. - process.exitCode = 1 - } + // 🌟Render the command. + try { + const { waitUntilExit } = render( + + + + + , + { debug: !!args.debug }, + ); + await waitUntilExit(); + } catch (err) { + // Errors are handled/reported in ErrorBoundary. + process.exitCode = 1; + } - // If this isn't a valid command, make sure we exit with a non-zero exit code. - if (!isValidCommand) { - process.exitCode = 1 - } - } catch (error) { - // If an error was thrown in the command logic above (but outside of the ErrorBoundary in Component) - // then render an ErrorBoundary. - try { - const { waitUntilExit } = render( - - - , - { - debug: !!args.debug, - } - ) - await waitUntilExit() - } catch { - // Errors are handled/reported in ErrorBoundary. - process.exitCode = 1 - } - } - } + // If this isn't a valid command, make sure we exit with a non-zero exit code. + if (!isValidCommand) { + process.exitCode = 1; + } + } catch (error) { + // If an error was thrown in the command logic above (but outside of the ErrorBoundary in Component) + // then render an ErrorBoundary. + try { + const { waitUntilExit } = render( + + + , + { + debug: !!args.debug, + }, + ); + await waitUntilExit(); + } catch { + // Errors are handled/reported in ErrorBoundary. + process.exitCode = 1; + } + } + }; } /** Helper to fetch the name of the current yargs CLI command. */ function getCommand(args: yargs.Arguments) { - return args._.length === 0 ? 'update' : args._.join(' ') + return args._.length === 0 ? 'update' : args._.join(' '); } /** * Helper to generate the shared library properties shared by all analytics calls. */ async function rudderTyperLibraryProperties( - args: yargs.Arguments, - cfg: Config | undefined = undefined + args: yargs.Arguments, + cfg: Config | undefined = undefined, ) { - // In CI environments, or if there is no internet, we may not be able to execute the - // the token script. - let tokenMethod = undefined - try { - tokenMethod = await getTokenMethod(cfg, args.config) - } catch {} + // In CI environments, or if there is no internet, we may not be able to execute the + // the token script. + let tokenMethod = undefined; + try { + tokenMethod = await getTokenMethod(cfg, args.config); + } catch {} - // Attempt to read the name of the Tracking Plan from a local `plan.json`. - // If this fails, that's fine -- we'll still have the id from the config. - let trackingPlanName = '' - try { - if (cfg && cfg.trackingPlans.length > 0) { - const tp = await loadTrackingPlan(args.config, cfg.trackingPlans[0]) - if (tp) { - trackingPlanName = tp.display_name - } - } - } catch {} + // Attempt to read the name of the Tracking Plan from a local `plan.json`. + // If this fails, that's fine -- we'll still have the id from the config. + let trackingPlanName = ''; + try { + if (cfg && cfg.trackingPlans.length > 0) { + const tp = await loadTrackingPlan(args.config, cfg.trackingPlans[0]); + if (tp) { + trackingPlanName = getTrackingPlanName(tp); + } + } + } catch {} - return { - version, - client: cfg && { - language: cfg.client.language, - sdk: cfg.client.sdk, - }, - command: getCommand(args), - is_ci: Boolean(process.env.CI), - token_method: tokenMethod, - tracking_plan: - cfg && cfg.trackingPlans && cfg.trackingPlans.length > 0 - ? { - name: trackingPlanName, - id: cfg.trackingPlans[0].id, - workspace_slug: cfg.trackingPlans[0].workspaceSlug, - } - : undefined, - } + return { + version, + client: cfg && { + language: cfg.client.language, + sdk: cfg.client.sdk, + }, + command: getCommand(args), + is_ci: Boolean(process.env.CI), + token_method: tokenMethod, + tracking_plan: + cfg && cfg.trackingPlans && cfg.trackingPlans.length > 0 + ? { + name: trackingPlanName, + id: cfg.trackingPlans[0].id, + workspace_slug: cfg.trackingPlans[0].workspaceSlug, + } + : undefined, + }; } /** @@ -274,5 +275,5 @@ async function rudderTyperLibraryProperties( * the same user together. */ async function getAnonymousId() { - return await machineId(false) + return await machineId(false); } diff --git a/src/generators/android/android.ts b/src/generators/android/android.ts index 4fc2ec15..0e53fbbd 100644 --- a/src/generators/android/android.ts +++ b/src/generators/android/android.ts @@ -1,153 +1,153 @@ -import { camelCase, upperFirst } from 'lodash' -import { Type, Schema, getPropertiesSchema } from '../ast' -import { Generator, GeneratorClient } from '../gen' +import { camelCase, upperFirst } from 'lodash'; +import { Type, Schema, getPropertiesSchema } from '../ast'; +import { Generator, GeneratorClient } from '../gen'; // These contexts are what will be passed to Handlebars to perform rendering. // Everything in these contexts should be properly sanitized. type AndroidObjectContext = { - // The formatted name for this object, ex: "ProductClicked" - name: string -} + // The formatted name for this object, ex: "ProductClicked" + name: string; +}; type AndroidPropertyContext = { - // The formatted name for this property, ex: "numAvocados". - name: string - // The type of this property. ex: "String". - type: string - // Whether the property is nullable (@NonNull vs @Nullable modifier). - isVariableNullable: boolean - // Whether runtime error should be thrown for null payload value - shouldThrowRuntimeError: boolean | undefined - // Whether this is a List property - isListType: boolean - // Whether this is property can be serialized with toProperties() - implementsSerializableProperties: boolean -} + // The formatted name for this property, ex: "numAvocados". + name: string; + // The type of this property. ex: "String". + type: string; + // Whether the property is nullable (@NonNull vs @Nullable modifier). + isVariableNullable: boolean; + // Whether runtime error should be thrown for null payload value + shouldThrowRuntimeError: boolean | undefined; + // Whether this is a List property + isListType: boolean; + // Whether this is property can be serialized with toProperties() + implementsSerializableProperties: boolean; +}; type AndroidTrackCallContext = { - // The formatted function name, ex: "orderCompleted". - functionName: string - propsType: string - propsParam: boolean -} + // The formatted function name, ex: "orderCompleted". + functionName: string; + propsType: string; + propsParam: boolean; +}; export const android: Generator< - Record, - AndroidTrackCallContext, - AndroidObjectContext, - AndroidPropertyContext + Record, + AndroidTrackCallContext, + AndroidObjectContext, + AndroidPropertyContext > = { - generatePropertiesObject: true, - namer: { - // See: https://github.com/AnanthaRajuCprojects/Reserved-Key-Words-list-of-various-programming-languages/blob/master/Java%20Keywords%20List.md - // prettier-ignore - reservedWords: [ + generatePropertiesObject: true, + namer: { + // See: https://github.com/AnanthaRajuCprojects/Reserved-Key-Words-list-of-various-programming-languages/blob/master/Java%20Keywords%20List.md + // prettier-ignore + reservedWords: [ "abstract", "assert", "boolean", "break", "byte", "case", "catch", "char", "class", "const", "continue", "default", "do", "double", "else", "enum", "extends", "final", "finally", "float", "for", "goto", "if", "implement", "imports", "instanceof", "int", "interface", "long", "native", "new", "package", "private", "protected", "public", "return", "short", "static", "strictfp", "super", "switch", "synchronized", "this", "throw", "throws", "transient", "try", "void", "volatile", "while" ], - quoteChar: '"', - allowedIdentifierStartingChars: 'A-Za-z_', - allowedIdentifierChars: 'A-Za-z0-9_', - }, - generatePrimitive: async (client, schema, parentPath) => { - let type = 'Object' + quoteChar: '"', + allowedIdentifierStartingChars: 'A-Za-z_', + allowedIdentifierChars: 'A-Za-z0-9_', + }, + generatePrimitive: async (client, schema, parentPath) => { + let type = 'Object'; - if (schema.type === Type.STRING) { - type = 'String' - } else if (schema.type === Type.BOOLEAN) { - type = 'Boolean' - } else if (schema.type === Type.INTEGER) { - type = 'Long' - } else if (schema.type === Type.NUMBER) { - type = 'Double' - } + if (schema.type === Type.STRING) { + type = 'String'; + } else if (schema.type === Type.BOOLEAN) { + type = 'Boolean'; + } else if (schema.type === Type.INTEGER) { + type = 'Long'; + } else if (schema.type === Type.NUMBER) { + type = 'Double'; + } - return { - ...defaultPropertyContext(client, schema, type, parentPath), - } - }, - setup: async () => ({}), - generateArray: async (client, schema, items, parentPath) => { - return { - ...defaultPropertyContext(client, schema, `List<${items.type}>`, parentPath), - isListType: true, - } - }, - generateObject: async (client, schema, properties, parentPath) => { - const property = defaultPropertyContext(client, schema, 'Object', parentPath) - let object: AndroidObjectContext | undefined + return { + ...defaultPropertyContext(client, schema, type, parentPath), + }; + }, + setup: async () => ({}), + generateArray: async (client, schema, items, parentPath) => { + return { + ...defaultPropertyContext(client, schema, `List<${items.type}>`, parentPath), + isListType: true, + }; + }, + generateObject: async (client, schema, properties, parentPath) => { + const property = defaultPropertyContext(client, schema, 'Object', parentPath); + let object: AndroidObjectContext | undefined; - if (properties.length > 0) { - const className = client.namer.register(schema.name, 'class', { - transform: (name: string) => { - return upperFirst(camelCase(name)) - }, - }) + if (properties.length > 0) { + const className = client.namer.register(schema.name, 'class', { + transform: (name: string) => { + return upperFirst(camelCase(name)); + }, + }); - property.type = className - property.implementsSerializableProperties = true - object = { - name: className, - } - } + property.type = className; + property.implementsSerializableProperties = true; + object = { + name: className, + }; + } - return { property, object } - }, - generateUnion: async (client, schema, _, parentPath) => { - // TODO: support unions - return defaultPropertyContext(client, schema, 'Object', parentPath) - }, - generateTrackCall: async (_client, schema, functionName, propertiesObject) => { - const { properties } = getPropertiesSchema(schema) - return { - class: schema.name.replace(/\s/g, ''), - functionName: functionName, - propsType: propertiesObject.type, - propsParam: !!properties.length, - } - }, - generateRoot: async (client, context) => { - await Promise.all([ - client.generateFile( - 'RudderTyperAnalytics.java', - 'generators/android/templates/RudderTyperAnalytics.java.hbs', - context - ), - client.generateFile( - 'RudderTyperUtils.java', - 'generators/android/templates/RudderTyperUtils.java.hbs', - context - ), - client.generateFile( - 'SerializableProperties.java', - 'generators/android/templates/SerializableProperties.java.hbs', - context - ), - ...context.objects.map(o => - client.generateFile(`${o.name}.java`, 'generators/android/templates/class.java.hbs', o) - ), - ]) - }, -} + return { property, object }; + }, + generateUnion: async (client, schema, _, parentPath) => { + // TODO: support unions + return defaultPropertyContext(client, schema, 'Object', parentPath); + }, + generateTrackCall: async (_client, schema, functionName, propertiesObject) => { + const { properties } = getPropertiesSchema(schema); + return { + class: schema.name.replace(/\s/g, ''), + functionName: functionName, + propsType: propertiesObject.type, + propsParam: !!properties.length, + }; + }, + generateRoot: async (client, context) => { + await Promise.all([ + client.generateFile( + 'RudderTyperAnalytics.java', + 'generators/android/templates/RudderTyperAnalytics.java.hbs', + context, + ), + client.generateFile( + 'RudderTyperUtils.java', + 'generators/android/templates/RudderTyperUtils.java.hbs', + context, + ), + client.generateFile( + 'SerializableProperties.java', + 'generators/android/templates/SerializableProperties.java.hbs', + context, + ), + ...context.objects.map(o => + client.generateFile(`${o.name}.java`, 'generators/android/templates/class.java.hbs', o), + ), + ]); + }, +}; function defaultPropertyContext( - client: GeneratorClient, - schema: Schema, - type: string, - namespace: string + client: GeneratorClient, + schema: Schema, + type: string, + namespace: string, ): AndroidPropertyContext { - return { - name: client.namer.register(schema.name, namespace, { - transform: camelCase, - }), - type, - isVariableNullable: !schema.isRequired || !!schema.isNullable, - shouldThrowRuntimeError: schema.isRequired && !schema.isNullable, - isListType: false, - implementsSerializableProperties: false, - } + return { + name: client.namer.register(schema.name, namespace, { + transform: camelCase, + }), + type, + isVariableNullable: !schema.isRequired || !!schema.isNullable, + shouldThrowRuntimeError: schema.isRequired && !schema.isNullable, + isListType: false, + implementsSerializableProperties: false, + }; } diff --git a/src/generators/android/index.ts b/src/generators/android/index.ts index aa533284..2c167e82 100644 --- a/src/generators/android/index.ts +++ b/src/generators/android/index.ts @@ -1 +1 @@ -export { android } from './android' +export { android } from './android'; diff --git a/src/generators/ast.ts b/src/generators/ast.ts index 6debdb3b..fc3e5486 100644 --- a/src/generators/ast.ts +++ b/src/generators/ast.ts @@ -1,277 +1,277 @@ -import { JSONSchema7 } from 'json-schema' +import { JSONSchema7 } from 'json-schema'; // Schema represents a JSON Schema, however the representation // differs in ways that make it easier for codegen. // It does not seek to represent all of JSON Schema, only the subset that // is meaningful for codegen. Full JSON Schema validation should be done // at run-time and should be supported by all RudderTyper clients. -export type Schema = PrimitiveTypeSchema | ArrayTypeSchema | ObjectTypeSchema | UnionTypeSchema +export type Schema = PrimitiveTypeSchema | ArrayTypeSchema | ObjectTypeSchema | UnionTypeSchema; -export type PrimitiveTypeSchema = SchemaMetadata & PrimitiveTypeFields -export type ArrayTypeSchema = SchemaMetadata & ArrayTypeFields -export type ObjectTypeSchema = SchemaMetadata & ObjectTypeFields -export type UnionTypeSchema = SchemaMetadata & UnionTypeFields +export type PrimitiveTypeSchema = SchemaMetadata & PrimitiveTypeFields; +export type ArrayTypeSchema = SchemaMetadata & ArrayTypeFields; +export type ObjectTypeSchema = SchemaMetadata & ObjectTypeFields; +export type UnionTypeSchema = SchemaMetadata & UnionTypeFields; export type SchemaMetadata = { - name: string - description?: string - isRequired?: boolean - isNullable?: boolean -} + name: string; + description?: string; + isRequired?: boolean; + isNullable?: boolean; +}; export type TypeSpecificFields = - | PrimitiveTypeFields - | ArrayTypeFields - | ObjectTypeFields - | UnionTypeFields + | PrimitiveTypeFields + | ArrayTypeFields + | ObjectTypeFields + | UnionTypeFields; // TODO: consider whether non-primitive types should have an enum. // For unions, potentially the enum should just be within the primitive type // and filtered down to the relevant enum values. export type Enumable = { - // enum optionally represents a specific set of allowed values. - // Note: const fields (from JSON Schema) are treated as 1-element enums. - enum?: EnumValue[] -} + // enum optionally represents a specific set of allowed values. + // Note: const fields (from JSON Schema) are treated as 1-element enums. + enum?: EnumValue[]; +}; // Note: we don't support objects or arrays as enums, for simplification purposes. -export type EnumValue = string | number | boolean | null +export type EnumValue = string | number | boolean | null; export type PrimitiveTypeFields = Enumable & { - type: Type.STRING | Type.INTEGER | Type.NUMBER | Type.BOOLEAN | Type.ANY -} + type: Type.STRING | Type.INTEGER | Type.NUMBER | Type.BOOLEAN | Type.ANY; +}; export type ArrayTypeFields = { - type: Type.ARRAY - // items specifies the type of any items in this array. - items: TypeSpecificFields -} + type: Type.ARRAY; + // items specifies the type of any items in this array. + items: TypeSpecificFields; +}; export type ObjectTypeFields = { - type: Type.OBJECT - // properties specifies all of the expected properties in this object. - // Note: if an empty properties list is passed, all properties should be allowed. - properties: Schema[] -} + type: Type.OBJECT; + // properties specifies all of the expected properties in this object. + // Note: if an empty properties list is passed, all properties should be allowed. + properties: Schema[]; +}; export type UnionTypeFields = Enumable & { - type: Type.UNION - types: TypeSpecificFields[] -} + type: Type.UNION; + types: TypeSpecificFields[]; +}; // Type is a standardization of the various JSON Schema types. It removes the concept // of a "null" type, and introduces Unions and an explicit Any type. The Any type is // part of the JSON Schema spec, but it isn't an explicit type. export enum Type { - ANY, - STRING, - BOOLEAN, - INTEGER, - NUMBER, - OBJECT, - ARRAY, - UNION, + ANY, + STRING, + BOOLEAN, + INTEGER, + NUMBER, + OBJECT, + ARRAY, + UNION, } function toType(t: string): Type { - switch (t) { - case 'string': - return Type.STRING - case 'integer': - return Type.INTEGER - case 'number': - return Type.NUMBER - case 'boolean': - return Type.BOOLEAN - case 'object': - return Type.OBJECT - case 'array': - return Type.ARRAY - case 'null': - return Type.ANY - default: - throw new Error(`Unsupported type: ${t}`) - } + switch (t) { + case 'string': + return Type.STRING; + case 'integer': + return Type.INTEGER; + case 'number': + return Type.NUMBER; + case 'boolean': + return Type.BOOLEAN; + case 'object': + return Type.OBJECT; + case 'array': + return Type.ARRAY; + case 'null': + return Type.ANY; + default: + throw new Error(`Unsupported type: ${t}`); + } } // getPropertiesSchema extracts the Schema for `.properties` from an // event schema. export function getPropertiesSchema(event: Schema): ObjectTypeSchema { - let properties: ObjectTypeSchema | undefined = undefined - - // Events should always be a type="object" at the root, anything - // else would not match on a RudderStack analytics event. - if (event.type === Type.OBJECT) { - const propertiesSchema = event.properties.find( - (schema: Schema): boolean => schema.name === 'properties' - ) - // The schema representing `.properties` in the RudderStack analytics - // event should also always be an object. - if (propertiesSchema && propertiesSchema.type === Type.OBJECT) { - properties = propertiesSchema - } - } - - return { - // If `.properties` doesn't exist in the user-supplied JSON Schema, - // default to an empty object schema as a sane default. - type: Type.OBJECT, - properties: [], - ...(properties || {}), - isRequired: properties ? !!properties.isRequired : false, - isNullable: false, - // Use the event's name and description when generating an interface - // to represent these properties. - name: event.name, - description: event.description, - } + let properties: ObjectTypeSchema | undefined = undefined; + + // Events should always be a type="object" at the root, anything + // else would not match on a RudderStack analytics event. + if (event.type === Type.OBJECT) { + const propertiesSchema = event.properties.find( + (schema: Schema): boolean => schema.name === 'properties', + ); + // The schema representing `.properties` in the RudderStack analytics + // event should also always be an object. + if (propertiesSchema && propertiesSchema.type === Type.OBJECT) { + properties = propertiesSchema; + } + } + + return { + // If `.properties` doesn't exist in the user-supplied JSON Schema, + // default to an empty object schema as a sane default. + type: Type.OBJECT, + properties: [], + ...(properties || {}), + isRequired: properties ? !!properties.isRequired : false, + isNullable: false, + // Use the event's name and description when generating an interface + // to represent these properties. + name: event.name, + description: event.description, + }; } // parse transforms a JSON Schema into a standardized Schema. export function parse(raw: JSONSchema7, name?: string, isRequired?: boolean): Schema { - // TODO: validate that the raw JSON Schema is a valid JSON Schema before attempting to parse it. + // TODO: validate that the raw JSON Schema is a valid JSON Schema before attempting to parse it. - // Parse the relevant fields from the JSON Schema based on the type. - const typeSpecificFields = parseTypeSpecificFields(raw, getType(raw)) + // Parse the relevant fields from the JSON Schema based on the type. + const typeSpecificFields = parseTypeSpecificFields(raw, getType(raw)); - const schema: Schema = { - name: name || raw.title || '', - ...typeSpecificFields, - } + const schema: Schema = { + name: name || raw.title || '', + ...typeSpecificFields, + }; - if (raw.description) { - schema.description = raw.description - } + if (raw.description) { + schema.description = raw.description; + } - if (isRequired) { - schema.isRequired = true - } + if (isRequired) { + schema.isRequired = true; + } - if (isNullable(raw)) { - schema.isNullable = true - } + if (isNullable(raw)) { + schema.isNullable = true; + } - return schema + return schema; } // parseTypeSpecificFields extracts the relevant fields from the raw JSON Schema, // interpreting the schema based on the provided Type. function parseTypeSpecificFields(raw: JSONSchema7, type: Type): TypeSpecificFields { - if (type === Type.OBJECT) { - const fields: ObjectTypeFields = { type, properties: [] } - const requiredFields = new Set(raw.required || []) - for (const entry of Object.entries(raw.properties || {})) { - const [property, propertySchema] = entry - if (typeof propertySchema !== 'boolean') { - const isRequired = requiredFields.has(property) - fields.properties.push(parse(propertySchema, property, isRequired)) - } - } - - return fields - } else if (type === Type.ARRAY) { - const fields: ArrayTypeFields = { type, items: { type: Type.ANY } } - if (typeof raw.items !== 'boolean' && raw.items !== undefined) { - // `items` can be a single schemas, or an array of schemas, so standardize on an array. - const definitions = raw.items instanceof Array ? raw.items : [raw.items] - - // Convert from JSONSchema7Definition -> JSONSchema7 - const schemas = definitions.filter(def => typeof def !== 'boolean') as JSONSchema7[] - - if (schemas.length === 1) { - const schema = schemas[0] - fields.items = parseTypeSpecificFields(schema, getType(schema)) - } else if (schemas.length > 1) { - fields.items = { - type: Type.UNION, - types: schemas.map(schema => parseTypeSpecificFields(schema, getType(schema))), - } - } - } - - return fields - } else if (type === Type.UNION) { - const fields: UnionTypeFields = { type, types: [] } - for (const val of getRawTypes(raw).values()) { - // For codegen purposes, we don't consider "null" as a type, so remove it. - if (val === 'null') { - continue - } - - fields.types.push(parseTypeSpecificFields(raw, toType(val))) - } - - if (raw.enum) { - fields.enum = getEnum(raw) - } - - return fields - } else { - const fields: PrimitiveTypeFields = { type } - - // TODO: Per above comment, consider filtering the enum values to just the matching type (string, boolean, etc.). - if (raw.enum) { - fields.enum = getEnum(raw) - } - - // Handle the special case of `type: "null"`. In this case, only the value "null" - // is allowed, so treat this as a single-value enum. - const rawTypes = getRawTypes(raw) - if (rawTypes.has('null') && rawTypes.size === 1) { - fields.enum = [null] - } - - return fields - } + if (type === Type.OBJECT) { + const fields: ObjectTypeFields = { type, properties: [] }; + const requiredFields = new Set(raw.required || []); + for (const entry of Object.entries(raw.properties || {})) { + const [property, propertySchema] = entry; + if (typeof propertySchema !== 'boolean') { + const isRequired = requiredFields.has(property); + fields.properties.push(parse(propertySchema, property, isRequired)); + } + } + + return fields; + } else if (type === Type.ARRAY) { + const fields: ArrayTypeFields = { type, items: { type: Type.ANY } }; + if (typeof raw.items !== 'boolean' && raw.items !== undefined) { + // `items` can be a single schemas, or an array of schemas, so standardize on an array. + const definitions = raw.items instanceof Array ? raw.items : [raw.items]; + + // Convert from JSONSchema7Definition -> JSONSchema7 + const schemas = definitions.filter(def => typeof def !== 'boolean') as JSONSchema7[]; + + if (schemas.length === 1) { + const schema = schemas[0]; + fields.items = parseTypeSpecificFields(schema, getType(schema)); + } else if (schemas.length > 1) { + fields.items = { + type: Type.UNION, + types: schemas.map(schema => parseTypeSpecificFields(schema, getType(schema))), + }; + } + } + + return fields; + } else if (type === Type.UNION) { + const fields: UnionTypeFields = { type, types: [] }; + for (const val of getRawTypes(raw).values()) { + // For codegen purposes, we don't consider "null" as a type, so remove it. + if (val === 'null') { + continue; + } + + fields.types.push(parseTypeSpecificFields(raw, toType(val))); + } + + if (raw.enum) { + fields.enum = getEnum(raw); + } + + return fields; + } else { + const fields: PrimitiveTypeFields = { type }; + + // TODO: Per above comment, consider filtering the enum values to just the matching type (string, boolean, etc.). + if (raw.enum) { + fields.enum = getEnum(raw); + } + + // Handle the special case of `type: "null"`. In this case, only the value "null" + // is allowed, so treat this as a single-value enum. + const rawTypes = getRawTypes(raw); + if (rawTypes.has('null') && rawTypes.size === 1) { + fields.enum = [null]; + } + + return fields; + } } // getRawTypes returns the types for a given raw JSON Schema. These correspond // with the standard JSON Schema types (null, string, etc.) function getRawTypes(raw: JSONSchema7): Set { - // JSON Schema's `type` field is either an array or a string -- standardize it into an array. - const rawTypes = new Set() - if (typeof raw.type === 'string') { - rawTypes.add(raw.type) - } else if (raw.type instanceof Array) { - raw.type.forEach(t => rawTypes.add(t)) - } - - return rawTypes + // JSON Schema's `type` field is either an array or a string -- standardize it into an array. + const rawTypes = new Set(); + if (typeof raw.type === 'string') { + rawTypes.add(raw.type); + } else if (raw.type instanceof Array) { + raw.type.forEach(t => rawTypes.add(t)); + } + + return rawTypes; } // getType parses the raw types from a JSON Schema and returns the standardized Type. function getType(raw: JSONSchema7): Type { - const rawTypes = getRawTypes(raw) - // For codegen purposes, we don't consider "null" as a type, so remove it. - rawTypes.delete('null') - - let type = Type.ANY - if (rawTypes.size === 1) { - type = toType(rawTypes.values().next().value) - } else if (rawTypes.size >= 1) { - type = Type.UNION - } - - return type + const rawTypes = getRawTypes(raw); + // For codegen purposes, we don't consider "null" as a type, so remove it. + rawTypes.delete('null'); + + let type = Type.ANY; + if (rawTypes.size === 1) { + type = toType(rawTypes.values().next().value); + } else if (rawTypes.size >= 1) { + type = Type.UNION; + } + + return type; } // isNullable returns true if `null` is a valid value for this JSON Schema. function isNullable(raw: JSONSchema7): boolean { - const typeAllowsNull = getRawTypes(raw).has('null') || getType(raw) === Type.ANY - const enumAllowsNull = !raw.enum || raw.enum.includes(null) || raw.enum.includes('null') + const typeAllowsNull = getRawTypes(raw).has('null') || getType(raw) === Type.ANY; + const enumAllowsNull = !raw.enum || raw.enum.includes(null) || raw.enum.includes('null'); - return typeAllowsNull && enumAllowsNull + return typeAllowsNull && enumAllowsNull; } // getEnum parses the enum, if specified function getEnum(raw: JSONSchema7): EnumValue[] | undefined { - if (!raw.enum) { - return undefined - } + if (!raw.enum) { + return undefined; + } - const enm = raw.enum.filter( - val => ['boolean', 'number', 'string'].includes(typeof val) || val === null - ) as EnumValue[] + const enm = raw.enum.filter( + val => ['boolean', 'number', 'string'].includes(typeof val) || val === null, + ) as EnumValue[]; - return enm + return enm; } diff --git a/src/generators/gen.ts b/src/generators/gen.ts index 299e45a6..028983a0 100644 --- a/src/generators/gen.ts +++ b/src/generators/gen.ts @@ -1,87 +1,87 @@ -import { JSONSchema7 } from 'json-schema' -import { parse, Schema, getPropertiesSchema, Type } from './ast' -import { javascript } from './javascript' -import { objc } from './objc' -import { swift } from './swift' -import { android } from './android' -import { Options, SDK, Language } from './options' -import { registerStandardHelpers, generateFromTemplate } from '../templates' -import { Namer, Options as NamerOptions } from './namer' -import stringify from 'json-stable-stringify' -import { camelCase, upperFirst } from 'lodash' +import { JSONSchema7 } from 'json-schema'; +import { parse, Schema, getPropertiesSchema, Type } from './ast'; +import { javascript } from './javascript'; +import { objc } from './objc'; +import { swift } from './swift'; +import { android } from './android'; +import { Options, SDK, Language } from './options'; +import { registerStandardHelpers, generateFromTemplate } from '../templates'; +import { Namer, Options as NamerOptions } from './namer'; +import stringify from 'json-stable-stringify'; +import { camelCase, upperFirst } from 'lodash'; export type File = { - path: string - contents: string -} + path: string; + contents: string; +}; export type RawTrackingPlan = { - name: string - url: string - id: string - version: string - path: string - trackCalls: JSONSchema7[] -} + name: string; + url: string; + id: string; + version: string; + path: string; + trackCalls: JSONSchema7[]; +}; export type TrackingPlan = { - url: string - id: string - version: string - trackCalls: { - raw: JSONSchema7 - schema: Schema - }[] -} + url: string; + id: string; + version: string; + trackCalls: { + raw: JSONSchema7; + schema: Schema; + }[]; +}; export type BaseRootContext< - T extends Record, - O extends Record, - P extends Record + T extends Record, + O extends Record, + P extends Record > = { - isDevelopment: boolean - language: string - rudderTyperVersion: string - trackingPlanURL: string - tracks: (T & BaseTrackCallContext

)[] - objects: (O & BaseObjectContext

)[] -} + isDevelopment: boolean; + language: string; + rudderTyperVersion: string; + trackingPlanURL: string; + tracks: (T & BaseTrackCallContext

)[]; + objects: (O & BaseObjectContext

)[]; +}; export type BaseTrackCallContext

> = { - // The optional function description. - functionDescription?: string - // The raw JSON Schema for this event. - rawJSONSchema: string - // The raw version of the name of this track call (the name sent to RudderStack). - rawEventName: string - // The property parameters on this track call. Included if generatePropertiesObject=false. - properties?: (P & BasePropertyContext)[] -} + // The optional function description. + functionDescription?: string; + // The raw JSON Schema for this event. + rawJSONSchema: string; + // The raw version of the name of this track call (the name sent to RudderStack). + rawEventName: string; + // The property parameters on this track call. Included if generatePropertiesObject=false. + properties?: (P & BasePropertyContext)[]; +}; export type BaseObjectContext

> = { - description?: string - properties: (P & BasePropertyContext)[] -} + description?: string; + properties: (P & BasePropertyContext)[]; +}; export type BasePropertyContext = { - // The raw name of this property. ex: "user id" - rawName: string - // The AST type of this property. ex: Type.INTEGER - schemaType: Type - // The optional description of this property. - description?: string - isRequired: boolean -} + // The raw name of this property. ex: "user id" + rawName: string; + // The AST type of this property. ex: Type.INTEGER + schemaType: Type; + // The optional description of this property. + description?: string; + isRequired: boolean; +}; export type GeneratorClient = { - options: GenOptions - namer: Namer - generateFile: >( - outputPath: string, - templatePath: string, - context: T - ) => Promise -} + options: GenOptions; + namer: Namer; + generateFile: >( + outputPath: string, + templatePath: string, + context: T, + ) => Promise; +}; /* * Adding a new language to RudderTyper involves implementing the interface below. The logic to traverse a * JSON Schema and apply this generator is in `runGenerator` below. @@ -91,274 +91,274 @@ export type GeneratorClient = { * as parameters to each function. You can toggle this behavior with `generatePropertiesObject`. */ export declare type Generator< - R extends Record, - T extends Record, - O extends Record, - P extends Record + R extends Record, + T extends Record, + O extends Record, + P extends Record > = { - namer: NamerOptions - setup: (options: GenOptions) => Promise - generatePrimitive: (client: GeneratorClient, schema: Schema, parentPath: string) => Promise

- generateArray: ( - client: GeneratorClient, - schema: Schema, - items: P & BasePropertyContext, - parentPath: string - ) => Promise

- generateObject: ( - client: GeneratorClient, - schema: Schema, - properties: (P & BasePropertyContext)[], - parentPath: string - ) => Promise<{ property: P; object?: O }> - generateUnion: ( - client: GeneratorClient, - schema: Schema, - types: (P & BasePropertyContext)[], - parentPath: string - ) => Promise

- generateRoot: (client: GeneratorClient, context: R & BaseRootContext) => Promise - formatFile?: (client: GeneratorClient, file: File) => File + namer: NamerOptions; + setup: (options: GenOptions) => Promise; + generatePrimitive: (client: GeneratorClient, schema: Schema, parentPath: string) => Promise

; + generateArray: ( + client: GeneratorClient, + schema: Schema, + items: P & BasePropertyContext, + parentPath: string, + ) => Promise

; + generateObject: ( + client: GeneratorClient, + schema: Schema, + properties: (P & BasePropertyContext)[], + parentPath: string, + ) => Promise<{ property: P; object?: O }>; + generateUnion: ( + client: GeneratorClient, + schema: Schema, + types: (P & BasePropertyContext)[], + parentPath: string, + ) => Promise

; + generateRoot: (client: GeneratorClient, context: R & BaseRootContext) => Promise; + formatFile?: (client: GeneratorClient, file: File) => File; } & ( - | { - generatePropertiesObject: true - generateTrackCall: ( - client: GeneratorClient, - schema: Schema, - functionName: string, - propertiesObject: P & BasePropertyContext - ) => Promise - } - | { - generatePropertiesObject: false - generateTrackCall: ( - client: GeneratorClient, - schema: Schema, - functionName: string, - properties: (P & BasePropertyContext)[] - ) => Promise - } -) + | { + generatePropertiesObject: true; + generateTrackCall: ( + client: GeneratorClient, + schema: Schema, + functionName: string, + propertiesObject: P & BasePropertyContext, + ) => Promise; + } + | { + generatePropertiesObject: false; + generateTrackCall: ( + client: GeneratorClient, + schema: Schema, + functionName: string, + properties: (P & BasePropertyContext)[], + ) => Promise; + } +); export type GenOptions = { - // Configuration options configured by the ruddertyper.yml config. - client: Options - // The version of the RudderTyper CLI that is being used to generate clients. - // Used for analytics purposes by the RudderTyper team. - rudderTyperVersion: string - // Whether or not to generate a development bundle. If so, analytics payloads will - // be validated against the full JSON Schema before being sent to the underlying - // analytics instance. - isDevelopment: boolean -} + // Configuration options configured by the ruddertyper.yml config. + client: Options; + // The version of the RudderTyper CLI that is being used to generate clients. + // Used for analytics purposes by the RudderTyper team. + rudderTyperVersion: string; + // Whether or not to generate a development bundle. If so, analytics payloads will + // be validated against the full JSON Schema before being sent to the underlying + // analytics instance. + isDevelopment: boolean; +}; export async function gen(trackingPlan: RawTrackingPlan, options: GenOptions): Promise { - const parsedTrackingPlan = { - url: trackingPlan.url, - id: trackingPlan.id, - version: trackingPlan.version, - trackCalls: trackingPlan.trackCalls.map(s => { - const sanitizedSchema = { - $schema: 'http://json-schema.org/draft-07/schema#', - ...s, - } - return { - raw: sanitizedSchema, - schema: parse(sanitizedSchema), - } - }), - } + const parsedTrackingPlan = { + url: trackingPlan.url, + id: trackingPlan.id, + version: trackingPlan.version, + trackCalls: trackingPlan.trackCalls.map(s => { + const sanitizedSchema = { + $schema: 'http://json-schema.org/draft-07/schema#', + ...s, + }; + return { + raw: sanitizedSchema, + schema: parse(sanitizedSchema), + }; + }), + }; - if (options.client.sdk === SDK.WEB || options.client.sdk === SDK.NODE) { - return await runGenerator(javascript, parsedTrackingPlan, options) - } else if (options.client.sdk === SDK.IOS) { - if (options.client.language === Language.SWIFT) { - return await runGenerator(swift, parsedTrackingPlan, options) - } else { - return await runGenerator(objc, parsedTrackingPlan, options) - } - } else if (options.client.sdk === SDK.ANDROID) { - return await runGenerator(android, parsedTrackingPlan, options) - } else { - throw new Error(`Invalid SDK: ${options.client.sdk}`) - } + if (options.client.sdk === SDK.WEB || options.client.sdk === SDK.NODE) { + return await runGenerator(javascript, parsedTrackingPlan, options); + } else if (options.client.sdk === SDK.IOS) { + if (options.client.language === Language.SWIFT) { + return await runGenerator(swift, parsedTrackingPlan, options); + } else { + return await runGenerator(objc, parsedTrackingPlan, options); + } + } else if (options.client.sdk === SDK.ANDROID) { + return await runGenerator(android, parsedTrackingPlan, options); + } else { + throw new Error(`Invalid SDK: ${options.client.sdk}`); + } } async function runGenerator< - R extends Record, - T extends Record, - O extends Record, - P extends Record + R extends Record, + T extends Record, + O extends Record, + P extends Record >( - generator: Generator, - trackingPlan: TrackingPlan, - options: GenOptions + generator: Generator, + trackingPlan: TrackingPlan, + options: GenOptions, ): Promise { - // One-time setup. - registerStandardHelpers() - const rootContext = await generator.setup(options) - const context: R & BaseRootContext = { - ...rootContext, - isDevelopment: options.isDevelopment, - language: options.client.language, - sdk: options.client.sdk, - rudderTyperVersion: options.rudderTyperVersion, - trackingPlanURL: trackingPlan.url, - trackingPlanId: trackingPlan.id, - trackingPlanVersion: trackingPlan.version, - tracks: [], - objects: [], - } + // One-time setup. + registerStandardHelpers(); + const rootContext = await generator.setup(options); + const context: R & BaseRootContext = { + ...rootContext, + isDevelopment: options.isDevelopment, + language: options.client.language, + sdk: options.client.sdk, + rudderTyperVersion: options.rudderTyperVersion, + trackingPlanURL: trackingPlan.url, + trackingPlanId: trackingPlan.id, + trackingPlanVersion: trackingPlan.version, + tracks: [], + objects: [], + }; - // File output. - const files: File[] = [] - const generateFile = async >( - outputPath: string, - templatePath: string, - fileContext: C - ) => { - files.push({ - path: outputPath, - contents: await generateFromTemplate>(templatePath, { - ...context, - ...fileContext, - }), - }) - } + // File output. + const files: File[] = []; + const generateFile = async >( + outputPath: string, + templatePath: string, + fileContext: C, + ) => { + files.push({ + path: outputPath, + contents: await generateFromTemplate>(templatePath, { + ...context, + ...fileContext, + }), + }); + }; - const client: GeneratorClient = { - options, - namer: new Namer(generator.namer), - generateFile, - } + const client: GeneratorClient = { + options, + namer: new Namer(generator.namer), + generateFile, + }; - // Core generator logic. This logic involves traversing over the underlying JSON Schema - // and calling out to the supplied generator with each "node" in the JSON Schema that, - // based on its AST type. Each iteration of this loop generates a "property" which - // represents the type for a given schema. This property contains metadata such as the - // type name (string, FooBarInterface, etc.), descriptions, etc. that are used in - // templates. - const traverseSchema = async ( - schema: Schema, - parentPath: string, - eventName: string - ): Promise

=> { - const path = `${parentPath}->${schema.name}` - const base = { - rawName: client.namer.escapeString(schema.name), - schemaType: schema.type, - description: schema.description, - isRequired: !!schema.isRequired, - } - let p: P - if ([Type.ANY, Type.STRING, Type.BOOLEAN, Type.INTEGER, Type.NUMBER].includes(schema.type)) { - // Primitives are any type that doesn't require generating a "subtype". - p = await generator.generatePrimitive(client, schema, parentPath) - } else if (schema.type === Type.OBJECT) { - // For objects, we need to recursively generate each property first. - const properties: (P & BasePropertyContext)[] = [] - for (const property of schema.properties) { - properties.push(await traverseSchema(property, path, eventName)) - } + // Core generator logic. This logic involves traversing over the underlying JSON Schema + // and calling out to the supplied generator with each "node" in the JSON Schema that, + // based on its AST type. Each iteration of this loop generates a "property" which + // represents the type for a given schema. This property contains metadata such as the + // type name (string, FooBarInterface, etc.), descriptions, etc. that are used in + // templates. + const traverseSchema = async ( + schema: Schema, + parentPath: string, + eventName: string, + ): Promise

=> { + const path = `${parentPath}->${schema.name}`; + const base = { + rawName: client.namer.escapeString(schema.name), + schemaType: schema.type, + description: schema.description, + isRequired: !!schema.isRequired, + }; + let p: P; + if ([Type.ANY, Type.STRING, Type.BOOLEAN, Type.INTEGER, Type.NUMBER].includes(schema.type)) { + // Primitives are any type that doesn't require generating a "subtype". + p = await generator.generatePrimitive(client, schema, parentPath); + } else if (schema.type === Type.OBJECT) { + // For objects, we need to recursively generate each property first. + const properties: (P & BasePropertyContext)[] = []; + for (const property of schema.properties) { + properties.push(await traverseSchema(property, path, eventName)); + } - if (parentPath !== '') { - schema.name = eventName + upperFirst(schema.name) - } + if (parentPath !== '') { + schema.name = eventName + upperFirst(schema.name); + } - const { property, object } = await generator.generateObject( - client, - schema, - properties, - parentPath - ) - if (object) { - context.objects.push({ - properties, - ...object, - }) - } - p = property - } else if (schema.type === Type.ARRAY) { - // Arrays are another special case, because we need to generate a type to represent - // the items allowed in this array. - const itemsSchema: Schema = { - name: schema.name + ' Item', - description: schema.description, - ...schema.items, - } - const items = await traverseSchema(itemsSchema, path, eventName) - p = await generator.generateArray(client, schema, items, parentPath) - } else if (schema.type === Type.UNION) { - // For unions, we generate a property type to represent each of the possible types - // then use that list of possible property types to generate a union. - const types = await Promise.all( - schema.types.map(async t => { - const subSchema = { - name: schema.name, - description: schema.description, - ...t, - } + const { property, object } = await generator.generateObject( + client, + schema, + properties, + parentPath, + ); + if (object) { + context.objects.push({ + properties, + ...object, + }); + } + p = property; + } else if (schema.type === Type.ARRAY) { + // Arrays are another special case, because we need to generate a type to represent + // the items allowed in this array. + const itemsSchema: Schema = { + name: schema.name + ' Item', + description: schema.description, + ...schema.items, + }; + const items = await traverseSchema(itemsSchema, path, eventName); + p = await generator.generateArray(client, schema, items, parentPath); + } else if (schema.type === Type.UNION) { + // For unions, we generate a property type to represent each of the possible types + // then use that list of possible property types to generate a union. + const types = await Promise.all( + schema.types.map(async t => { + const subSchema = { + name: schema.name, + description: schema.description, + ...t, + }; - return await traverseSchema(subSchema, path, eventName) - }) - ) - p = await generator.generateUnion(client, schema, types, parentPath) - } else { - throw new Error(`Invalid Schema Type: ${schema.type}`) - } + return await traverseSchema(subSchema, path, eventName); + }), + ); + p = await generator.generateUnion(client, schema, types, parentPath); + } else { + throw new Error(`Invalid Schema Type: ${schema.type}`); + } - return { - ...base, - ...p, - } - } - // Generate Track Calls. - for (const { raw, schema } of trackingPlan.trackCalls) { - let t: T - const functionName: string = client.namer.register(schema.name, 'function->track', { - transform: camelCase, - }) - if (generator.generatePropertiesObject) { - const p = await traverseSchema(getPropertiesSchema(schema), '', functionName) - t = await generator.generateTrackCall(client, schema, functionName, p) - } else { - const properties: (P & BasePropertyContext)[] = [] - for (const property of getPropertiesSchema(schema).properties) { - properties.push(await traverseSchema(property, schema.name, functionName)) - } - t = { - ...(await generator.generateTrackCall(client, schema, functionName, properties)), - properties, - } - } + return { + ...base, + ...p, + }; + }; + // Generate Track Calls. + for (const { raw, schema } of trackingPlan.trackCalls) { + let t: T; + const functionName: string = client.namer.register(schema.name, 'function->track', { + transform: camelCase, + }); + if (generator.generatePropertiesObject) { + const p = await traverseSchema(getPropertiesSchema(schema), '', functionName); + t = await generator.generateTrackCall(client, schema, functionName, p); + } else { + const properties: (P & BasePropertyContext)[] = []; + for (const property of getPropertiesSchema(schema).properties) { + properties.push(await traverseSchema(property, schema.name, functionName)); + } + t = { + ...(await generator.generateTrackCall(client, schema, functionName, properties)), + properties, + }; + } - context.tracks.push({ - functionDescription: schema.description, - rawJSONSchema: stringify(raw, { - space: '\t', - }), - rawEventName: client.namer.escapeString(schema.name), - ...t, - }) - } - // Perform any root-level generation. - await generator.generateRoot(client, context) + context.tracks.push({ + functionDescription: schema.description, + rawJSONSchema: stringify(raw, { + space: '\t', + }), + rawEventName: client.namer.escapeString(schema.name), + ...t, + }); + } + // Perform any root-level generation. + await generator.generateRoot(client, context); - // Format and output all generated files. - return files.map(f => (generator.formatFile ? generator.formatFile(client, f) : f)) + // Format and output all generated files. + return files.map(f => (generator.formatFile ? generator.formatFile(client, f) : f)); } // Legacy Code: export type TemplateBaseContext = { - isDevelopment: boolean - language: string - rudderTyperVersion: string -} + isDevelopment: boolean; + language: string; + rudderTyperVersion: string; +}; export function baseContext(options: GenOptions): TemplateBaseContext { - return { - isDevelopment: options.isDevelopment, - language: options.client.language, - rudderTyperVersion: options.rudderTyperVersion, - } + return { + isDevelopment: options.isDevelopment, + language: options.client.language, + rudderTyperVersion: options.rudderTyperVersion, + }; } diff --git a/src/generators/javascript/index.ts b/src/generators/javascript/index.ts index 970046f1..7a6d6e55 100644 --- a/src/generators/javascript/index.ts +++ b/src/generators/javascript/index.ts @@ -1 +1 @@ -export { javascript } from './javascript' +export { javascript } from './javascript'; diff --git a/src/generators/javascript/javascript.ts b/src/generators/javascript/javascript.ts index 24f1733a..6bfebd0b 100644 --- a/src/generators/javascript/javascript.ts +++ b/src/generators/javascript/javascript.ts @@ -1,200 +1,200 @@ -import { Type, Schema } from '../ast' -import { camelCase, upperFirst } from 'lodash' -import * as prettier from 'prettier' -import { transpileModule } from 'typescript' -import { Language, SDK } from '../options' -import { Generator } from '../gen' -import { toTarget, toModule } from './targets' -import { registerPartial } from '../../templates' +import { Type, Schema } from '../ast'; +import { camelCase, upperFirst } from 'lodash'; +import * as prettier from 'prettier'; +import { transpileModule } from 'typescript'; +import { Language, SDK } from '../options'; +import { Generator } from '../gen'; +import { toTarget, toModule } from './targets'; +import { registerPartial } from '../../templates'; // These contexts are what will be passed to Handlebars to perform rendering. // Everything in these contexts should be properly sanitized. type JavaScriptRootContext = { - isBrowser: boolean - useProxy: boolean -} + isBrowser: boolean; + useProxy: boolean; +}; // Represents a single exposed track() call. type JavaScriptTrackCallContext = { - // The formatted function name, ex: "orderCompleted". - functionName: string - // The type of the analytics properties object. - propertiesType: string - // The properties field is only optional in analytics.js environments where - // no properties are required. - isPropertiesOptional: boolean -} + // The formatted function name, ex: "orderCompleted". + functionName: string; + // The type of the analytics properties object. + propertiesType: string; + // The properties field is only optional in analytics.js environments where + // no properties are required. + isPropertiesOptional: boolean; +}; type JavaScriptObjectContext = { - // The formatted name for this object, ex: "Planet" - name: string -} + // The formatted name for this object, ex: "Planet" + name: string; +}; type JavaScriptPropertyContext = { - // The formatted name for this property, ex: "numAvocados". - name: string - // The type of this property. ex: "number". - type: string -} + // The formatted name for this property, ex: "numAvocados". + name: string; + // The type of this property. ex: "number". + type: string; +}; export const javascript: Generator< - JavaScriptRootContext, - JavaScriptTrackCallContext, - JavaScriptObjectContext, - JavaScriptPropertyContext + JavaScriptRootContext, + JavaScriptTrackCallContext, + JavaScriptObjectContext, + JavaScriptPropertyContext > = { - generatePropertiesObject: true, - namer: { - // See: https://mathiasbynens.be/notes/reserved-keywords#ecmascript-6 - // prettier-ignore - reservedWords: [ + generatePropertiesObject: true, + namer: { + // See: https://mathiasbynens.be/notes/reserved-keywords#ecmascript-6 + // prettier-ignore + reservedWords: [ 'do', 'if', 'in', 'for', 'let', 'new', 'try', 'var', 'case', 'else', 'enum', 'eval', 'null', 'this', 'true', 'void', 'with', 'await', 'break', 'catch', 'class', 'const', 'false', 'super', 'throw', 'while', 'yield', 'delete', 'export', 'import', 'public', 'return', 'static', 'switch', 'typeof', 'default', 'extends', 'finally', 'package', 'private', 'continue', 'debugger', 'function', 'arguments', 'interface', 'protected', 'implements', 'instanceof', ], - quoteChar: "'", - // Note: we don't support the full range of allowed JS chars, instead focusing on a subset. - // The full regex 11k+ chars: https://mathiasbynens.be/demo/javascript-identifier-regex - // See: https://mathiasbynens.be/notes/javascript-identifiers-es6 - allowedIdentifierStartingChars: 'A-Za-z_$', - allowedIdentifierChars: 'A-Za-z0-9_$', - }, - setup: async options => { - await registerPartial( - 'generators/javascript/templates/setRudderTyperOptionsDocumentation.hbs', - 'setRudderTyperOptionsDocumentation' - ) - await registerPartial( - 'generators/javascript/templates/functionDocumentation.hbs', - 'functionDocumentation' - ) + quoteChar: "'", + // Note: we don't support the full range of allowed JS chars, instead focusing on a subset. + // The full regex 11k+ chars: https://mathiasbynens.be/demo/javascript-identifier-regex + // See: https://mathiasbynens.be/notes/javascript-identifiers-es6 + allowedIdentifierStartingChars: 'A-Za-z_$', + allowedIdentifierChars: 'A-Za-z0-9_$', + }, + setup: async options => { + await registerPartial( + 'generators/javascript/templates/setRudderTyperOptionsDocumentation.hbs', + 'setRudderTyperOptionsDocumentation', + ); + await registerPartial( + 'generators/javascript/templates/functionDocumentation.hbs', + 'functionDocumentation', + ); - return { - isBrowser: options.client.sdk === SDK.WEB, - useProxy: true, - } - }, - generatePrimitive: async (client, schema) => { - let type = 'any' - if (schema.type === Type.STRING) { - type = 'string' - } else if (schema.type === Type.BOOLEAN) { - type = 'boolean' - } else if (schema.type === Type.INTEGER || schema.type === Type.NUMBER) { - type = 'number' - } + return { + isBrowser: options.client.sdk === SDK.WEB, + useProxy: true, + }; + }, + generatePrimitive: async (client, schema) => { + let type = 'any'; + if (schema.type === Type.STRING) { + type = 'string'; + } else if (schema.type === Type.BOOLEAN) { + type = 'boolean'; + } else if (schema.type === Type.INTEGER || schema.type === Type.NUMBER) { + type = 'number'; + } - return conditionallyNullable(schema, { - name: client.namer.escapeString(schema.name), - type, - }) - }, - generateArray: async (client, schema, items) => - conditionallyNullable(schema, { - name: client.namer.escapeString(schema.name), - type: `${items.type}[]`, - }), - generateObject: async (client, schema, properties) => { - if (properties.length === 0) { - // If no properties are set, replace this object with a untyped map to allow any properties. - return { - property: conditionallyNullable(schema, { - name: client.namer.escapeString(schema.name), - type: 'Record', - }), - } - } else { - // Otherwise generate an interface to represent this object. - const interfaceName = client.namer.register(schema.name, 'interface', { - transform: (name: string) => upperFirst(camelCase(name)), - }) - return { - property: conditionallyNullable(schema, { - name: client.namer.escapeString(schema.name), - type: interfaceName, - }), - object: { - name: interfaceName, - }, - } - } - }, - generateUnion: async (client, schema, types) => - conditionallyNullable(schema, { - name: client.namer.escapeString(schema.name), - type: types.map(t => t.type).join(' | '), - }), - generateTrackCall: async (client, _schema, functionName, propertiesObject) => ({ - functionName: functionName, - propertiesType: propertiesObject.type, - // The properties object in a.js can be omitted if no properties are required. - isPropertiesOptional: client.options.client.sdk === SDK.WEB && !propertiesObject.isRequired, - }), - generateRoot: async (client, context) => { - // index.hbs contains all JavaScript client logic. - await client.generateFile( - client.options.client.language === Language.TYPESCRIPT ? 'index.ts' : 'index.js', - 'generators/javascript/templates/index.hbs', - context - ) + return conditionallyNullable(schema, { + name: client.namer.escapeString(schema.name), + type, + }); + }, + generateArray: async (client, schema, items) => + conditionallyNullable(schema, { + name: client.namer.escapeString(schema.name), + type: `${items.type}[]`, + }), + generateObject: async (client, schema, properties) => { + if (properties.length === 0) { + // If no properties are set, replace this object with a untyped map to allow any properties. + return { + property: conditionallyNullable(schema, { + name: client.namer.escapeString(schema.name), + type: 'Record', + }), + }; + } else { + // Otherwise generate an interface to represent this object. + const interfaceName = client.namer.register(schema.name, 'interface', { + transform: (name: string) => upperFirst(camelCase(name)), + }); + return { + property: conditionallyNullable(schema, { + name: client.namer.escapeString(schema.name), + type: interfaceName, + }), + object: { + name: interfaceName, + }, + }; + } + }, + generateUnion: async (client, schema, types) => + conditionallyNullable(schema, { + name: client.namer.escapeString(schema.name), + type: types.map(t => t.type).join(' | '), + }), + generateTrackCall: async (client, _schema, functionName, propertiesObject) => ({ + functionName: functionName, + propertiesType: propertiesObject.type, + // The properties object in a.js can be omitted if no properties are required. + isPropertiesOptional: client.options.client.sdk === SDK.WEB && !propertiesObject.isRequired, + }), + generateRoot: async (client, context) => { + // index.hbs contains all JavaScript client logic. + await client.generateFile( + client.options.client.language === Language.TYPESCRIPT ? 'index.ts' : 'index.js', + 'generators/javascript/templates/index.hbs', + context, + ); - // rudder.hbs contains the TypeScript definitions for the Rudder API. - // It becomes an empty file for JavaScript after being transpiled. - if (client.options.client.language === Language.TYPESCRIPT) { - await client.generateFile( - 'rudder.ts', - 'generators/javascript/templates/rudder.hbs', - context - ) - } - }, - formatFile: (client, file) => { - let { contents } = file - // If we are generating a JavaScript client, transpile the client - // from TypeScript into JavaScript. - if (client.options.client.language === Language.JAVASCRIPT) { - // If we're generating a JavaScript client, compile from TypeScript to JavaScript. - const { outputText } = transpileModule(contents, { - compilerOptions: { - target: toTarget(client.options.client.scriptTarget), - module: toModule(client.options.client.moduleTarget), - esModuleInterop: true, - }, - }) + // rudder.hbs contains the TypeScript definitions for the Rudder API. + // It becomes an empty file for JavaScript after being transpiled. + if (client.options.client.language === Language.TYPESCRIPT) { + await client.generateFile( + 'rudder.ts', + 'generators/javascript/templates/rudder.hbs', + context, + ); + } + }, + formatFile: (client, file) => { + let { contents } = file; + // If we are generating a JavaScript client, transpile the client + // from TypeScript into JavaScript. + if (client.options.client.language === Language.JAVASCRIPT) { + // If we're generating a JavaScript client, compile from TypeScript to JavaScript. + const { outputText } = transpileModule(contents, { + compilerOptions: { + target: toTarget(client.options.client.scriptTarget), + module: toModule(client.options.client.moduleTarget), + esModuleInterop: true, + }, + }); - contents = outputText - } + contents = outputText; + } - // Apply stylistic formatting, via Prettier. - const formattedContents = prettier.format(contents, { - parser: client.options.client.language === Language.TYPESCRIPT ? 'typescript' : 'babel', - // Overwrite a few of the standard prettier settings to match with our RudderTyper configuration: - tabWidth: 2, - singleQuote: true, - semi: false, - trailingComma: - client.options.client.language === Language.JAVASCRIPT && - client.options.client.scriptTarget === 'ES3' - ? 'none' - : 'es5', - }) + // Apply stylistic formatting, via Prettier. + const formattedContents = prettier.format(contents, { + parser: client.options.client.language === Language.TYPESCRIPT ? 'typescript' : 'babel', + // Overwrite a few of the standard prettier settings to match with our RudderTyper configuration: + tabWidth: 2, + singleQuote: true, + semi: false, + trailingComma: + client.options.client.language === Language.JAVASCRIPT && + client.options.client.scriptTarget === 'ES3' + ? 'none' + : 'es5', + }); - return { - ...file, - contents: formattedContents, - } - }, -} + return { + ...file, + contents: formattedContents, + }; + }, +}; function conditionallyNullable( - schema: Schema, - property: JavaScriptPropertyContext + schema: Schema, + property: JavaScriptPropertyContext, ): JavaScriptPropertyContext { - return { - ...property, - type: !!schema.isNullable ? `${property.type} | null` : property.type, - } + return { + ...property, + type: !!schema.isNullable ? `${property.type} | null` : property.type, + }; } diff --git a/src/generators/javascript/targets.ts b/src/generators/javascript/targets.ts index 8a5c51f7..2863fdb3 100644 --- a/src/generators/javascript/targets.ts +++ b/src/generators/javascript/targets.ts @@ -1,55 +1,55 @@ // Helpers for mapping ruddertyper configuration options for module/script // targets to TypeScript's compiler enums. -import { ModuleKind, ScriptTarget } from 'typescript' +import { ModuleKind, ScriptTarget } from 'typescript'; export function toTarget(target: string | undefined): ScriptTarget { - if (!target) { - return ScriptTarget.ESNext - } + if (!target) { + return ScriptTarget.ESNext; + } - switch (target) { - case 'ES3': - return ScriptTarget.ES3 - case 'ES5': - return ScriptTarget.ES5 - case 'ES2015': - return ScriptTarget.ES2015 - case 'ES2016': - return ScriptTarget.ES2016 - case 'ES2017': - return ScriptTarget.ES2017 - case 'ES2018': - return ScriptTarget.ES2018 - case 'ES2019': - return ScriptTarget.ES2019 - case 'ESNext': - return ScriptTarget.ESNext - case 'Latest': - return ScriptTarget.Latest - default: - throw new Error(`Invalid scriptTarget: '${target}'`) - } + switch (target) { + case 'ES3': + return ScriptTarget.ES3; + case 'ES5': + return ScriptTarget.ES5; + case 'ES2015': + return ScriptTarget.ES2015; + case 'ES2016': + return ScriptTarget.ES2016; + case 'ES2017': + return ScriptTarget.ES2017; + case 'ES2018': + return ScriptTarget.ES2018; + case 'ES2019': + return ScriptTarget.ES2019; + case 'ESNext': + return ScriptTarget.ESNext; + case 'Latest': + return ScriptTarget.Latest; + default: + throw new Error(`Invalid scriptTarget: '${target}'`); + } } export function toModule(target: string | undefined): ModuleKind { - if (!target) { - return ModuleKind.ESNext - } + if (!target) { + return ModuleKind.ESNext; + } - switch (target) { - case 'CommonJS': - return ModuleKind.CommonJS - case 'AMD': - return ModuleKind.AMD - case 'UMD': - return ModuleKind.UMD - case 'System': - return ModuleKind.System - case 'ES2015': - return ModuleKind.ES2015 - case 'ESNext': - return ModuleKind.ESNext - default: - throw new Error(`Invalid moduleTarget: '${target}'`) - } + switch (target) { + case 'CommonJS': + return ModuleKind.CommonJS; + case 'AMD': + return ModuleKind.AMD; + case 'UMD': + return ModuleKind.UMD; + case 'System': + return ModuleKind.System; + case 'ES2015': + return ModuleKind.ES2015; + case 'ESNext': + return ModuleKind.ESNext; + default: + throw new Error(`Invalid moduleTarget: '${target}'`); + } } diff --git a/src/generators/namer.ts b/src/generators/namer.ts index 277f2bd7..7d11bb3c 100644 --- a/src/generators/namer.ts +++ b/src/generators/namer.ts @@ -1,152 +1,152 @@ export type Options = { - // Words that are reserved by a given language, and which should not be allowed - // for identifier names. - reservedWords: string[] - // String to use for quoted strings. Usually a single or double quote. - quoteChar: string - // A character set matching all characters that are allowed as the first character in an identifier. - allowedIdentifierStartingChars: string - // A character set matching all characters that are allowed within identifiers. - allowedIdentifierChars: string -} + // Words that are reserved by a given language, and which should not be allowed + // for identifier names. + reservedWords: string[]; + // String to use for quoted strings. Usually a single or double quote. + quoteChar: string; + // A character set matching all characters that are allowed as the first character in an identifier. + allowedIdentifierStartingChars: string; + // A character set matching all characters that are allowed within identifiers. + allowedIdentifierChars: string; +}; export type SanitizeOptions = { - // A transformation that is applied before collision detection, but after registering - // a name to the internal registry. It's recommended to apply a transform here, rather - // than before calling register() since transformations are oftentimes lossy (camelcase, - // f.e.) -- in other words, it can lead to collisions after transformation that don't exist - // before. - transform?: (name: string) => string - // A set of strings that can be used as prefixes to avoid a collision, before - // falling back on simple numeric transforms. - prefixes?: string[] - // An opaque identifier representing whatever this name represents. If the same - // name + id combination has been seen before, then the same sanitized name will - // be returned. This might be used, for example, to re-use interfaces if the JSON - // Schema matches. If not specified, the same - id?: string -} + // A transformation that is applied before collision detection, but after registering + // a name to the internal registry. It's recommended to apply a transform here, rather + // than before calling register() since transformations are oftentimes lossy (camelcase, + // f.e.) -- in other words, it can lead to collisions after transformation that don't exist + // before. + transform?: (name: string) => string; + // A set of strings that can be used as prefixes to avoid a collision, before + // falling back on simple numeric transforms. + prefixes?: string[]; + // An opaque identifier representing whatever this name represents. If the same + // name + id combination has been seen before, then the same sanitized name will + // be returned. This might be used, for example, to re-use interfaces if the JSON + // Schema matches. If not specified, the same + id?: string; +}; export class Namer { - private options: Options - // Maps namespace -> Set of sanitized names - private lookupByName: Record> - // Maps (namespace, name, id) -> sanitized name - private lookupByID: Record>> - - public constructor(options: Options) { - this.options = options - this.lookupByID = {} - this.lookupByName = {} - - // Add the various analytics calls as reserved words. - this.options.reservedWords.push( - // v1 - 'track', - 'identify', - 'group', - 'page', - 'screen', - 'alias', - // v2 - 'set' - ) - } - - /** - * register registers a name within a given namespace. The sanitized, collision-free name is returned. - * - * An optional transform function can be supplied, which'll be applied during the sanitization process. - */ - public register(name: string, namespace: string, options?: SanitizeOptions): string { - // If an id was provided, check if we have a cached sanitized name for this id. - if (options && options.id) { - if ( - this.lookupByID[namespace] && - this.lookupByID[namespace][name] && - this.lookupByID[namespace][name][options.id] - ) { - return this.lookupByID[namespace][name][options.id] - } - } - - if (!this.lookupByName[namespace]) { - this.lookupByName[namespace] = new Set() - } - - // Otherwise, we need to generate a new sanitized name. - const sanitizedName = this.uniqueify(name, namespace, options) - - // Reserve this newly generated name so that future calls will not re-reserve this name. - this.lookupByName[namespace].add(sanitizedName) - // Cache this newly generated name by the id. - if (options && options.id) { - if (!this.lookupByID[namespace]) { - this.lookupByID[namespace] = {} - } - if (!this.lookupByID[namespace][name]) { - this.lookupByID[namespace][name] = {} - } - this.lookupByID[namespace][name][options.id] = sanitizedName - } - - return sanitizedName - } - - /** - * escapeString escapes quotes (and escapes) within a string so that it can safely generated. - */ - public escapeString(str: string): string { - return str - .replace(/\\/g, '\\\\') - .replace(new RegExp(this.options.quoteChar, 'g'), `\\` + this.options.quoteChar) - } - - private uniqueify(name: string, namespace: string, options?: SanitizeOptions): string { - // Find a unique name by using a prefix/suffix if necessary. - let prefix = '' - let suffix = '' - while (this.lookupByName[namespace].has(this.sanitize(prefix + name + suffix, options))) { - if (options && options.prefixes && options.prefixes.length > 0) { - // If the user provided prefixes, first try to find a unique sanitized name with those prefixes. - prefix = options.prefixes.shift()! + '_' - suffix = '' - } else { - // Fallback on a numeric suffix. - prefix = '' - suffix = suffix === '' ? '1' : (parseInt(suffix) + 1).toString() - } - } - - return this.sanitize(prefix + name + suffix, options) - } - - private sanitize(name: string, options?: SanitizeOptions): string { - // Handle zero length names. - if (name.length === 0) { - name = 'EmptyIdentifier' - } - - // Apply the optional user-supplied transform. - if (options && options.transform) { - name = options.transform(name) - } - - // Handle names that are reserved words. - if (this.options.reservedWords.includes(name)) { - name += '_' - } - - // Replace invalid characters within the name. - const invalidChars = new RegExp(`[^${this.options.allowedIdentifierChars}]`, 'g') - name = name.replace(invalidChars, '_') - - // Handle names that start with an invalid character. - const invalidStartingChars = new RegExp(`^[^${this.options.allowedIdentifierStartingChars}]`) - if (invalidStartingChars.test(name)) { - name = `I${name}` - } - - return name - } + private options: Options; + // Maps namespace -> Set of sanitized names + private lookupByName: Record>; + // Maps (namespace, name, id) -> sanitized name + private lookupByID: Record>>; + + public constructor(options: Options) { + this.options = options; + this.lookupByID = {}; + this.lookupByName = {}; + + // Add the various analytics calls as reserved words. + this.options.reservedWords.push( + // v1 + 'track', + 'identify', + 'group', + 'page', + 'screen', + 'alias', + // v2 + 'set', + ); + } + + /** + * register registers a name within a given namespace. The sanitized, collision-free name is returned. + * + * An optional transform function can be supplied, which'll be applied during the sanitization process. + */ + public register(name: string, namespace: string, options?: SanitizeOptions): string { + // If an id was provided, check if we have a cached sanitized name for this id. + if (options && options.id) { + if ( + this.lookupByID[namespace] && + this.lookupByID[namespace][name] && + this.lookupByID[namespace][name][options.id] + ) { + return this.lookupByID[namespace][name][options.id]; + } + } + + if (!this.lookupByName[namespace]) { + this.lookupByName[namespace] = new Set(); + } + + // Otherwise, we need to generate a new sanitized name. + const sanitizedName = this.uniqueify(name, namespace, options); + + // Reserve this newly generated name so that future calls will not re-reserve this name. + this.lookupByName[namespace].add(sanitizedName); + // Cache this newly generated name by the id. + if (options && options.id) { + if (!this.lookupByID[namespace]) { + this.lookupByID[namespace] = {}; + } + if (!this.lookupByID[namespace][name]) { + this.lookupByID[namespace][name] = {}; + } + this.lookupByID[namespace][name][options.id] = sanitizedName; + } + + return sanitizedName; + } + + /** + * escapeString escapes quotes (and escapes) within a string so that it can safely generated. + */ + public escapeString(str: string): string { + return str + .replace(/\\/g, '\\\\') + .replace(new RegExp(this.options.quoteChar, 'g'), `\\` + this.options.quoteChar); + } + + private uniqueify(name: string, namespace: string, options?: SanitizeOptions): string { + // Find a unique name by using a prefix/suffix if necessary. + let prefix = ''; + let suffix = ''; + while (this.lookupByName[namespace].has(this.sanitize(prefix + name + suffix, options))) { + if (options && options.prefixes && options.prefixes.length > 0) { + // If the user provided prefixes, first try to find a unique sanitized name with those prefixes. + prefix = options.prefixes.shift()! + '_'; + suffix = ''; + } else { + // Fallback on a numeric suffix. + prefix = ''; + suffix = suffix === '' ? '1' : (parseInt(suffix) + 1).toString(); + } + } + + return this.sanitize(prefix + name + suffix, options); + } + + private sanitize(name: string, options?: SanitizeOptions): string { + // Handle zero length names. + if (name.length === 0) { + name = 'EmptyIdentifier'; + } + + // Apply the optional user-supplied transform. + if (options && options.transform) { + name = options.transform(name); + } + + // Handle names that are reserved words. + if (this.options.reservedWords.includes(name)) { + name += '_'; + } + + // Replace invalid characters within the name. + const invalidChars = new RegExp(`[^${this.options.allowedIdentifierChars}]`, 'g'); + name = name.replace(invalidChars, '_'); + + // Handle names that start with an invalid character. + const invalidStartingChars = new RegExp(`^[^${this.options.allowedIdentifierStartingChars}]`); + if (invalidStartingChars.test(name)) { + name = `I${name}`; + } + + return name; + } } diff --git a/src/generators/objc/index.ts b/src/generators/objc/index.ts index 9039255c..ab9aee3b 100644 --- a/src/generators/objc/index.ts +++ b/src/generators/objc/index.ts @@ -1 +1 @@ -export { objc } from './objc' +export { objc } from './objc'; diff --git a/src/generators/objc/objc.ts b/src/generators/objc/objc.ts index 9f9f0c4e..31aa94ca 100644 --- a/src/generators/objc/objc.ts +++ b/src/generators/objc/objc.ts @@ -1,52 +1,52 @@ -import { camelCase, upperFirst } from 'lodash' -import { Type, Schema } from '../ast' -import * as Handlebars from 'handlebars' -import { Generator, BasePropertyContext, GeneratorClient } from '../gen' +import { camelCase, upperFirst } from 'lodash'; +import { Type, Schema } from '../ast'; +import * as Handlebars from 'handlebars'; +import { Generator, BasePropertyContext, GeneratorClient } from '../gen'; // These contexts are what will be passed to Handlebars to perform rendering. // Everything in these contexts should be properly sanitized. type ObjCObjectContext = { - // The formatted name for this object, ex: "numAvocados". - name: string - // Set of files that need to be imported in this file. - imports: string[] -} + // The formatted name for this object, ex: "numAvocados". + name: string; + // Set of files that need to be imported in this file. + imports: string[]; +}; type ObjCPropertyContext = { - // The formatted name for this property, ex: "numAvocados". - name: string - // The type of this property. ex: "NSNumber". - type: string - // Stringified property modifiers. ex: "nonatomic, copy". - modifiers: string - // Whether the property is nullable (nonnull vs nullable modifier). - isVariableNullable: boolean - // Whether null is a valid value for this property when sent to Rudderstack. - isPayloadFieldNullable: boolean - // Whether the Objective-C type is a pointer (id, SERIALIZABLE_DICT, NSNumber *, ...). - isPointerType: boolean - // Note: only set if this is a class. - // The header file containing the interface for this class. - importName?: string -} + // The formatted name for this property, ex: "numAvocados". + name: string; + // The type of this property. ex: "NSNumber". + type: string; + // Stringified property modifiers. ex: "nonatomic, copy". + modifiers: string; + // Whether the property is nullable (nonnull vs nullable modifier). + isVariableNullable: boolean; + // Whether null is a valid value for this property when sent to Rudderstack. + isPayloadFieldNullable: boolean; + // Whether the Objective-C type is a pointer (id, SERIALIZABLE_DICT, NSNumber *, ...). + isPointerType: boolean; + // Note: only set if this is a class. + // The header file containing the interface for this class. + importName?: string; +}; type ObjCTrackCallContext = { - // The formatted function name, ex: "orderCompleted". - functionName: string -} + // The formatted function name, ex: "orderCompleted". + functionName: string; +}; export const objc: Generator< - Record, - ObjCTrackCallContext, - ObjCObjectContext, - ObjCPropertyContext + Record, + ObjCTrackCallContext, + ObjCObjectContext, + ObjCPropertyContext > = { - generatePropertiesObject: false, - namer: { - // See: https://github.com/AnanthaRajuCprojects/Reserved-Key-Words-list-of-various-programming-languages/blob/master/Objective-C%20Reserved%20Words.md - // prettier-ignore - reservedWords: [ + generatePropertiesObject: false, + namer: { + // See: https://github.com/AnanthaRajuCprojects/Reserved-Key-Words-list-of-various-programming-languages/blob/master/Objective-C%20Reserved%20Words.md + // prettier-ignore + reservedWords: [ 'asm', 'atomic', 'auto', 'bool', 'break', 'bycopy', 'byref', 'case', 'catch', 'char', 'class', 'const', 'continue', 'copy', 'debugDescription', 'default', 'description', 'do', 'double', 'dynamic', 'else', 'end', 'enum', 'extern', 'false', 'finally', 'float', @@ -58,262 +58,258 @@ export const objc: Generator< 'throw', 'true', 'try', 'typedef', 'typeof', 'union', 'unsigned', 'void', 'volatile', 'while', 'yes' ], - quoteChar: '"', - allowedIdentifierStartingChars: 'A-Za-z_$', - allowedIdentifierChars: 'A-Za-z0-9_$', - }, - setup: async () => { - Handlebars.registerHelper('propertiesDictionary', generatePropertiesDictionary) - Handlebars.registerHelper('functionCall', generateFunctionCall) - Handlebars.registerHelper('functionSignature', generateFunctionSignature) - Handlebars.registerHelper('variableSeparator', variableSeparator) - return {} - }, - generatePrimitive: async (client, schema, parentPath) => { - let type = 'id' - let isPointerType = !schema.isRequired || !!schema.isNullable + quoteChar: '"', + allowedIdentifierStartingChars: 'A-Za-z_$', + allowedIdentifierChars: 'A-Za-z0-9_$', + }, + setup: async () => { + Handlebars.registerHelper('propertiesDictionary', generatePropertiesDictionary); + Handlebars.registerHelper('functionCall', generateFunctionCall); + Handlebars.registerHelper('functionSignature', generateFunctionSignature); + Handlebars.registerHelper('variableSeparator', variableSeparator); + return {}; + }, + generatePrimitive: async (client, schema, parentPath) => { + let type = 'id'; + let isPointerType = !schema.isRequired || !!schema.isNullable; - if (schema.type === Type.STRING) { - type = 'NSString *' - isPointerType = true - } else if (schema.type === Type.BOOLEAN) { - // BOOLs cannot nullable in Objective-C. Instead, use an NSNumber which can be - // initialized like a boolean like so: [NSNumber numberWithBool:YES] - // This is what is done behind the scenes by ruddertyper if this boolean is nonnull. - type = isPointerType ? 'NSNumber *' : 'BOOL' - } else if (schema.type === Type.INTEGER) { - type = isPointerType ? 'NSNumber *' : 'NSInteger' - } else if (schema.type === Type.NUMBER) { - type = 'NSNumber *' - isPointerType = true - } + if (schema.type === Type.STRING) { + type = 'NSString *'; + isPointerType = true; + } else if (schema.type === Type.BOOLEAN) { + // BOOLs cannot nullable in Objective-C. Instead, use an NSNumber which can be + // initialized like a boolean like so: [NSNumber numberWithBool:YES] + // This is what is done behind the scenes by ruddertyper if this boolean is nonnull. + type = isPointerType ? 'NSNumber *' : 'BOOL'; + } else if (schema.type === Type.INTEGER) { + type = isPointerType ? 'NSNumber *' : 'NSInteger'; + } else if (schema.type === Type.NUMBER) { + type = 'NSNumber *'; + isPointerType = true; + } - return defaultPropertyContext(client, schema, type, parentPath, isPointerType) - }, - generateArray: async (client, schema, items, parentPath) => { - // Objective-C doesn't support NSArray's of primitives. Therefore, we - // map booleans and integers to NSNumbers. - const itemsType = [Type.BOOLEAN, Type.INTEGER].includes(items.schemaType) - ? 'NSNumber *' - : items.type + return defaultPropertyContext(client, schema, type, parentPath, isPointerType); + }, + generateArray: async (client, schema, items, parentPath) => { + // Objective-C doesn't support NSArray's of primitives. Therefore, we + // map booleans and integers to NSNumbers. + const itemsType = [Type.BOOLEAN, Type.INTEGER].includes(items.schemaType) + ? 'NSNumber *' + : items.type; - return { - ...defaultPropertyContext(client, schema, `NSArray<${itemsType}> *`, parentPath, true), - importName: items.importName, - } - }, - generateObject: async (client, schema, properties, parentPath) => { - const property = defaultPropertyContext(client, schema, 'NSDictionary *', parentPath, true) - let object: ObjCObjectContext | undefined = undefined + return { + ...defaultPropertyContext(client, schema, `NSArray<${itemsType}> *`, parentPath, true), + importName: items.importName, + }; + }, + generateObject: async (client, schema, properties, parentPath) => { + const property = defaultPropertyContext(client, schema, 'NSDictionary *', parentPath, true); + let object: ObjCObjectContext | undefined = undefined; - if (properties.length > 0) { - // If at least one property is set, generate a class that only allows the explicitly - // allowed properties. - const className = client.namer.register(schema.name, 'class', { - transform: (name: string) => { - return `RS${upperFirst(camelCase(name))}` - }, - }) - property.type = `${className} *` - property.importName = `"${className}.h"` - object = { - name: className, - imports: properties.filter(p => !!p.importName).map(p => p.importName!), - } - } + if (properties.length > 0) { + // If at least one property is set, generate a class that only allows the explicitly + // allowed properties. + const className = client.namer.register(schema.name, 'class', { + transform: (name: string) => { + return `RS${upperFirst(camelCase(name))}`; + }, + }); + property.type = `${className} *`; + property.importName = `"${className}.h"`; + object = { + name: className, + imports: properties.filter(p => !!p.importName).map(p => p.importName!), + }; + } - return { property, object } - }, - generateUnion: async (client, schema, _, parentPath) => { - // TODO: support unions in iOS - return defaultPropertyContext(client, schema, 'id', parentPath, true) - }, - generateTrackCall: async (_client, _schema, functionName) => ({ - functionName: functionName, - }), - generateRoot: async (client, context) => { - await Promise.all([ - client.generateFile( - 'RSRudderTyperAnalytics.h', - 'generators/objc/templates/analytics.h.hbs', - context - ), - client.generateFile( - 'RSRudderTyperAnalytics.m', - 'generators/objc/templates/analytics.m.hbs', - context - ), - client.generateFile( - 'RSRudderTyperUtils.h', - 'generators/objc/templates/RSRudderTyperUtils.h.hbs', - context - ), - client.generateFile( - 'RSRudderTyperUtils.m', - 'generators/objc/templates/RSRudderTyperUtils.m.hbs', - context - ), - client.generateFile( - 'RSRudderTyperSerializable.h', - 'generators/objc/templates/RSRudderTyperSerializable.h.hbs', - context - ), - ...context.objects.map(o => - client.generateFile(`${o.name}.h`, 'generators/objc/templates/class.h.hbs', o) - ), - ...context.objects.map(o => - client.generateFile(`${o.name}.m`, 'generators/objc/templates/class.m.hbs', o) - ), - ]) - }, -} + return { property, object }; + }, + generateUnion: async (client, schema, _, parentPath) => { + // TODO: support unions in iOS + return defaultPropertyContext(client, schema, 'id', parentPath, true); + }, + generateTrackCall: async (_client, _schema, functionName) => ({ + functionName: functionName, + }), + generateRoot: async (client, context) => { + await Promise.all([ + client.generateFile( + 'RSRudderTyperAnalytics.h', + 'generators/objc/templates/analytics.h.hbs', + context, + ), + client.generateFile( + 'RSRudderTyperAnalytics.m', + 'generators/objc/templates/analytics.m.hbs', + context, + ), + client.generateFile( + 'RSRudderTyperUtils.h', + 'generators/objc/templates/RSRudderTyperUtils.h.hbs', + context, + ), + client.generateFile( + 'RSRudderTyperUtils.m', + 'generators/objc/templates/RSRudderTyperUtils.m.hbs', + context, + ), + client.generateFile( + 'RSRudderTyperSerializable.h', + 'generators/objc/templates/RSRudderTyperSerializable.h.hbs', + context, + ), + ...context.objects.map(o => + client.generateFile(`${o.name}.h`, 'generators/objc/templates/class.h.hbs', o), + ), + ...context.objects.map(o => + client.generateFile(`${o.name}.m`, 'generators/objc/templates/class.m.hbs', o), + ), + ]); + }, +}; function defaultPropertyContext( - client: GeneratorClient, - schema: Schema, - type: string, - namespace: string, - isPointerType: boolean + client: GeneratorClient, + schema: Schema, + type: string, + namespace: string, + isPointerType: boolean, ): ObjCPropertyContext { - return { - name: client.namer.register(schema.name, namespace, { - transform: camelCase, - }), - type, - modifiers: isPointerType - ? schema.isRequired - ? 'strong, nonatomic, nonnull' - : 'strong, nonatomic, nullable' - : 'nonatomic', - isVariableNullable: !schema.isRequired || !!schema.isNullable, - isPayloadFieldNullable: !!schema.isNullable && !!schema.isRequired, - isPointerType, - } + return { + name: client.namer.register(schema.name, namespace, { + transform: camelCase, + }), + type, + modifiers: isPointerType + ? schema.isRequired + ? 'strong, nonatomic, nonnull' + : 'strong, nonatomic, nullable' + : 'nonatomic', + isVariableNullable: !schema.isRequired || !!schema.isNullable, + isPayloadFieldNullable: !!schema.isNullable && !!schema.isRequired, + isPointerType, + }; } // Handlebars partials function generateFunctionSignature( - functionName: string, - properties: (BasePropertyContext & ObjCPropertyContext)[], - withOptions: boolean + functionName: string, + properties: (BasePropertyContext & ObjCPropertyContext)[], + withOptions: boolean, ): string { - let signature = functionName - const parameters: { - type: string - name: string - isPointerType: boolean - isVariableNullable: boolean - }[] = [...properties] - if (withOptions) { - parameters.push({ - name: 'options', - type: 'RSOption *', - isPointerType: true, - isVariableNullable: true, - }) - } + let signature = functionName; + const parameters: { + type: string; + name: string; + isPointerType: boolean; + isVariableNullable: boolean; + }[] = [...properties]; + if (withOptions) { + parameters.push({ + name: 'options', + type: 'RSOption *', + isPointerType: true, + isVariableNullable: true, + }); + } - const withNullability = (property: { - type: string - isPointerType: boolean - isVariableNullable: boolean - }) => { - const { isPointerType, type, isVariableNullable } = property - return isPointerType ? `${isVariableNullable ? 'nullable' : 'nonnull'} ${type}` : type - } + const withNullability = (property: { + type: string; + isPointerType: boolean; + isVariableNullable: boolean; + }) => { + const { isPointerType, type, isVariableNullable } = property; + return isPointerType ? `${isVariableNullable ? 'nullable' : 'nonnull'} ${type}` : type; + }; - // Mutate the function name to match standard Objective-C naming standards (FooBar vs. FooBarWithSparkles:sparkles). - if (parameters.length > 0) { - const first = parameters[0] - signature += `With${upperFirst(first.name)}:(${withNullability(first)})${first.name}\n` - } - for (const parameter of parameters.slice(1)) { - signature += `${parameter.name}:(${withNullability(parameter)})${parameter.name}\n` - } + // Mutate the function name to match standard Objective-C naming standards (FooBar vs. FooBarWithSparkles:sparkles). + if (parameters.length > 0) { + const first = parameters[0]; + signature += `With${upperFirst(first.name)}:(${withNullability(first)})${first.name}\n`; + } + for (const parameter of parameters.slice(1)) { + signature += `${parameter.name}:(${withNullability(parameter)})${parameter.name}\n`; + } - return signature.trim() + return signature.trim(); } function generateFunctionCall( - caller: string, - functionName: string, - properties: (BasePropertyContext & ObjCPropertyContext)[], - extraParameterName?: string, - extraParameterValue?: string + caller: string, + functionName: string, + properties: (BasePropertyContext & ObjCPropertyContext)[], + extraParameterName?: string, + extraParameterValue?: string, ): string { - let functionCall = functionName - const parameters: { name: string; value: string }[] = properties.map(p => ({ - name: p.name, - value: p.name, - })) - if (extraParameterName && extraParameterValue) { - parameters.push({ - name: extraParameterName, - value: extraParameterValue, - }) - } + let functionCall = functionName; + const parameters: { name: string; value: string }[] = properties.map(p => ({ + name: p.name, + value: p.name, + })); + if (extraParameterName && extraParameterValue) { + parameters.push({ + name: extraParameterName, + value: extraParameterValue, + }); + } - if (parameters.length > 0) { - const { name, value } = parameters[0] - functionCall += `With${upperFirst(name)}:${value}` - } - for (const { name, value } of parameters.slice(1)) { - functionCall += ` ${name}:${value}` - } + if (parameters.length > 0) { + const { name, value } = parameters[0]; + functionCall += `With${upperFirst(name)}:${value}`; + } + for (const { name, value } of parameters.slice(1)) { + functionCall += ` ${name}:${value}`; + } - return `[${caller} ${functionCall.trim()}];` + return `[${caller} ${functionCall.trim()}];`; } function generatePropertiesDictionary( - properties: (BasePropertyContext & ObjCPropertyContext)[], - prefix?: string + properties: (BasePropertyContext & ObjCPropertyContext)[], + prefix?: string, ): string { - let out = 'NSMutableDictionary *properties = [[NSMutableDictionary alloc] init];\n' - for (const property of properties) { - const name = prefix && prefix.length > 0 ? `${prefix}${property.name}` : property.name - const serializableName = - property.schemaType === Type.BOOLEAN - ? property.isPointerType - ? name - : `[NSNumber numberWithBool:${name}]` - : property.schemaType === Type.INTEGER - ? property.isPointerType - ? name - : `[NSNumber numberWithInteger:${name}]` - : property.schemaType === Type.OBJECT && !property.type.includes('NSDictionary') - ? `[${name} toDictionary]` - : property.schemaType === Type.ARRAY - ? `[RSRudderTyperUtils toSerializableArray:${name}]` - : name + let out = 'NSMutableDictionary *properties = [[NSMutableDictionary alloc] init];\n'; + for (const property of properties) { + const name = prefix && prefix.length > 0 ? `${prefix}${property.name}` : property.name; + const serializableName = + property.schemaType === Type.BOOLEAN + ? property.isPointerType + ? name + : `[NSNumber numberWithBool:${name}]` + : property.schemaType === Type.INTEGER + ? property.isPointerType + ? name + : `[NSNumber numberWithInteger:${name}]` + : property.schemaType === Type.OBJECT && !property.type.includes('NSDictionary') + ? `[${name} toDictionary]` + : property.schemaType === Type.ARRAY + ? `[RSRudderTyperUtils toSerializableArray:${name}]` + : name; - let setter: string - if (property.isPointerType) { - if (property.isPayloadFieldNullable) { - // If the value is nil, we need to convert it from a primitive nil to NSNull (an object). - setter = `properties[@"${ - property.rawName - }"] = ${name} == nil ? [NSNull null] : ${serializableName};\n` - } else { - // If the property is not nullable, but is a pointer, then we need to guard on nil - // values. In that case, we don't set any value to the field. - // TODO: do we need these guards if we've already set a field as nonnull? TBD - setter = `if (${name} != nil) {\n properties[@"${ - property.rawName - }"] = ${serializableName};\n}\n` - } - } else { - setter = `properties[@"${property.rawName}"] = ${serializableName};\n` - } + let setter: string; + if (property.isPointerType) { + if (property.isPayloadFieldNullable) { + // If the value is nil, we need to convert it from a primitive nil to NSNull (an object). + setter = `properties[@"${property.rawName}"] = ${name} == nil ? [NSNull null] : ${serializableName};\n`; + } else { + // If the property is not nullable, but is a pointer, then we need to guard on nil + // values. In that case, we don't set any value to the field. + // TODO: do we need these guards if we've already set a field as nonnull? TBD + setter = `if (${name} != nil) {\n properties[@"${property.rawName}"] = ${serializableName};\n}\n`; + } + } else { + setter = `properties[@"${property.rawName}"] = ${serializableName};\n`; + } - out += setter - } + out += setter; + } - return out + return out; } // Render `NSString *foo` not `NSString * foo` and `BOOL foo` not `BOOLfoo` or `BOOL foo` by doing: // `{{type}}{{variableSeparator type}}{{name}}` function variableSeparator(type: string): string { - return type.endsWith('*') ? '' : ' ' + return type.endsWith('*') ? '' : ' '; } diff --git a/src/generators/options.ts b/src/generators/options.ts index 62d2345f..e3303b93 100644 --- a/src/generators/options.ts +++ b/src/generators/options.ts @@ -1,60 +1,60 @@ // Which RudderStack SDK to generate for. export enum SDK { - WEB = 'analytics.js', - NODE = 'analytics-node', - IOS = 'analytics-ios', - ANDROID = 'analytics-android', + WEB = 'analytics.js', + NODE = 'analytics-node', + IOS = 'analytics-ios', + ANDROID = 'analytics-android', } // Which language to generate clients for. export enum Language { - TYPESCRIPT = 'typescript', - JAVASCRIPT = 'javascript', - OBJECTIVE_C = 'objective-c', - SWIFT = 'swift', - JAVA = 'java', + TYPESCRIPT = 'typescript', + JAVASCRIPT = 'javascript', + OBJECTIVE_C = 'objective-c', + SWIFT = 'swift', + JAVA = 'java', } export type TypeScriptOptions = { - sdk: SDK.WEB | SDK.NODE - language: Language.TYPESCRIPT -} + sdk: SDK.WEB | SDK.NODE; + language: Language.TYPESCRIPT; +}; export type JavaScriptOptions = { - sdk: SDK.WEB | SDK.NODE - language: Language.JAVASCRIPT - // JavaScript transpilation settings: - scriptTarget?: - | 'ES3' - | 'ES5' - | 'ES2015' - | 'ES2016' - | 'ES2017' - | 'ES2018' - | 'ES2019' - | 'ESNext' - | 'Latest' - moduleTarget?: 'CommonJS' | 'AMD' | 'UMD' | 'System' | 'ES2015' | 'ESNext' -} + sdk: SDK.WEB | SDK.NODE; + language: Language.JAVASCRIPT; + // JavaScript transpilation settings: + scriptTarget?: + | 'ES3' + | 'ES5' + | 'ES2015' + | 'ES2016' + | 'ES2017' + | 'ES2018' + | 'ES2019' + | 'ESNext' + | 'Latest'; + moduleTarget?: 'CommonJS' | 'AMD' | 'UMD' | 'System' | 'ES2015' | 'ESNext'; +}; export type ObjectiveCOptions = { - sdk: SDK.IOS - language: Language.OBJECTIVE_C -} + sdk: SDK.IOS; + language: Language.OBJECTIVE_C; +}; export type SwiftOptions = { - sdk: SDK.IOS - language: Language.SWIFT -} + sdk: SDK.IOS; + language: Language.SWIFT; +}; export type JavaOptions = { - sdk: SDK.ANDROID - language: Language.JAVA -} + sdk: SDK.ANDROID; + language: Language.JAVA; +}; export type Options = - | JavaScriptOptions - | ObjectiveCOptions - | SwiftOptions - | JavaOptions - | TypeScriptOptions + | JavaScriptOptions + | ObjectiveCOptions + | SwiftOptions + | JavaOptions + | TypeScriptOptions; diff --git a/src/generators/swift/index.ts b/src/generators/swift/index.ts index bd5f1404..d564d2bf 100644 --- a/src/generators/swift/index.ts +++ b/src/generators/swift/index.ts @@ -1 +1 @@ -export { swift } from './swift' +export { swift } from './swift'; diff --git a/src/generators/swift/swift.ts b/src/generators/swift/swift.ts index 93e25917..18513df3 100644 --- a/src/generators/swift/swift.ts +++ b/src/generators/swift/swift.ts @@ -1,50 +1,50 @@ -import { camelCase, upperFirst } from 'lodash' -import { Type, Schema } from '../ast' -import * as Handlebars from 'handlebars' -import { Generator, BasePropertyContext, GeneratorClient } from '../gen' +import { camelCase, upperFirst } from 'lodash'; +import { Type, Schema } from '../ast'; +import * as Handlebars from 'handlebars'; +import { Generator, BasePropertyContext, GeneratorClient } from '../gen'; // These contexts are what will be passed to Handlebars to perform rendering. // Everything in these contexts should be properly sanitized. type SwiftObjectContext = { - // The formatted name for this object, ex: "numAvocados". - name: string - // Set of files that need to be imported in this file. - imports: string[] -} + // The formatted name for this object, ex: "numAvocados". + name: string; + // Set of files that need to be imported in this file. + imports: string[]; +}; type SwiftPropertyContext = { - // The formatted name for this property, ex: "numAvocados". - name: string - // The type of this property. ex: "NSNumber". - type: string - // Whether the property is nullable (nonnull vs nullable modifier). - isVariableNullable: boolean - // Whether null is a valid value for this property when sent to Rudderstack. - isPayloadFieldNullable: boolean - // Whether the Objective-C type is a pointer (id, SERIALIZABLE_DICT, NSNumber *, ...). - isPointerType: boolean - // Note: only set if this is a class. - // The header file containing the interface for this class. - importName?: string -} + // The formatted name for this property, ex: "numAvocados". + name: string; + // The type of this property. ex: "NSNumber". + type: string; + // Whether the property is nullable (nonnull vs nullable modifier). + isVariableNullable: boolean; + // Whether null is a valid value for this property when sent to Rudderstack. + isPayloadFieldNullable: boolean; + // Whether the Objective-C type is a pointer (id, SERIALIZABLE_DICT, NSNumber *, ...). + isPointerType: boolean; + // Note: only set if this is a class. + // The header file containing the interface for this class. + importName?: string; +}; type SwiftTrackCallContext = { - // The formatted function name, ex: "orderCompleted". - functionName: string -} + // The formatted function name, ex: "orderCompleted". + functionName: string; +}; export const swift: Generator< - Record, - SwiftTrackCallContext, - SwiftObjectContext, - SwiftPropertyContext + Record, + SwiftTrackCallContext, + SwiftObjectContext, + SwiftPropertyContext > = { - generatePropertiesObject: false, - namer: { - // See: https://docs.swift.org/swift-book/ReferenceManual/LexicalStructure.html#ID413 - // prettier-ignore - reservedWords: [ + generatePropertiesObject: false, + namer: { + // See: https://docs.swift.org/swift-book/ReferenceManual/LexicalStructure.html#ID413 + // prettier-ignore + reservedWords: [ 'associatedtype', 'class', 'deinit', 'enum', 'extension', 'fileprivate', 'func', 'import', 'init', 'inout', 'internal', 'let', 'open', 'operator', 'private', 'protocol', 'public', 'rethrows', 'static', 'struct', 'subscript', 'typealias', 'var', 'break', 'case', 'continue', 'default', 'defer', 'do', 'else', @@ -54,239 +54,237 @@ export const swift: Generator< 'mutating', 'none', 'nonmutating', 'optional', 'override', 'postfix', 'precedence', 'prefix', 'Protocol', 'required', 'right', 'set', 'Type', 'unowned', 'weak', 'willSet' ], - quoteChar: '"', - allowedIdentifierStartingChars: 'A-Za-z_$', - allowedIdentifierChars: 'A-Za-z0-9_$', - }, - setup: async () => { - Handlebars.registerHelper('propertiesDictionary', generatePropertiesDictionary) - Handlebars.registerHelper('functionCall', generateFunctionCall) - Handlebars.registerHelper('functionSignature', generateFunctionSignature) - return {} - }, - generatePrimitive: async (client, schema, parentPath) => { - let type = 'Any' - let isPointerType = !schema.isRequired || !!schema.isNullable + quoteChar: '"', + allowedIdentifierStartingChars: 'A-Za-z_$', + allowedIdentifierChars: 'A-Za-z0-9_$', + }, + setup: async () => { + Handlebars.registerHelper('propertiesDictionary', generatePropertiesDictionary); + Handlebars.registerHelper('functionCall', generateFunctionCall); + Handlebars.registerHelper('functionSignature', generateFunctionSignature); + return {}; + }, + generatePrimitive: async (client, schema, parentPath) => { + let type = 'Any'; + let isPointerType = !schema.isRequired || !!schema.isNullable; - if (schema.type === Type.STRING) { - type = 'String' - isPointerType = true - } else if (schema.type === Type.BOOLEAN) { - // BOOLs cannot nullable in Objective-C. Instead, use an NSNumber which can be - // initialized like a boolean like so: [NSNumber numberWithBool:YES] - // This is what is done behind the scenes by ruddertyper if this boolean is nonnull. - type = 'Bool' - } else if (schema.type === Type.INTEGER) { - type = 'Int' - } else if (schema.type === Type.NUMBER) { - type = 'Decimal' - isPointerType = true - } + if (schema.type === Type.STRING) { + type = 'String'; + isPointerType = true; + } else if (schema.type === Type.BOOLEAN) { + // BOOLs cannot nullable in Objective-C. Instead, use an NSNumber which can be + // initialized like a boolean like so: [NSNumber numberWithBool:YES] + // This is what is done behind the scenes by ruddertyper if this boolean is nonnull. + type = 'Bool'; + } else if (schema.type === Type.INTEGER) { + type = 'Int'; + } else if (schema.type === Type.NUMBER) { + type = 'Decimal'; + isPointerType = true; + } - return defaultPropertyContext(client, schema, type, parentPath, isPointerType) - }, - generateArray: async (client, schema, items, parentPath) => { - // Objective-C doesn't support NSArray's of primitives. Therefore, we - // map booleans and integers to NSNumbers. - const itemsType = items.type + return defaultPropertyContext(client, schema, type, parentPath, isPointerType); + }, + generateArray: async (client, schema, items, parentPath) => { + // Objective-C doesn't support NSArray's of primitives. Therefore, we + // map booleans and integers to NSNumbers. + const itemsType = items.type; - return { - ...defaultPropertyContext(client, schema, `[${itemsType}]`, parentPath, true), - importName: items.importName, - } - }, - generateObject: async (client, schema, properties, parentPath) => { - const property = defaultPropertyContext(client, schema, '[String: Any]', parentPath, true) - let object: SwiftObjectContext | undefined = undefined + return { + ...defaultPropertyContext(client, schema, `[${itemsType}]`, parentPath, true), + importName: items.importName, + }; + }, + generateObject: async (client, schema, properties, parentPath) => { + const property = defaultPropertyContext(client, schema, '[String: Any]', parentPath, true); + let object: SwiftObjectContext | undefined = undefined; - if (properties.length > 0) { - // If at least one property is set, generate a class that only allows the explicitly - // allowed properties. - const className = client.namer.register(schema.name, 'class', { - transform: (name: string) => { - return `${upperFirst(camelCase(name))}` - }, - }) - property.type = `${className}` - property.importName = `"${className}.h"` - object = { - name: className, - imports: properties.filter(p => !!p.importName).map(p => p.importName!), - } - } + if (properties.length > 0) { + // If at least one property is set, generate a class that only allows the explicitly + // allowed properties. + const className = client.namer.register(schema.name, 'class', { + transform: (name: string) => { + return `${upperFirst(camelCase(name))}`; + }, + }); + property.type = `${className}`; + property.importName = `"${className}.h"`; + object = { + name: className, + imports: properties.filter(p => !!p.importName).map(p => p.importName!), + }; + } - return { property, object } - }, - generateUnion: async (client, schema, _, parentPath) => { - // TODO: support unions in iOS - return defaultPropertyContext(client, schema, 'Any', parentPath, true) - }, - generateTrackCall: async (_client, _schema, functionName) => ({ - functionName: functionName, - }), - generateRoot: async (client, context) => { - await Promise.all([ - client.generateFile( - 'RudderTyperAnalytics.swift', - 'generators/swift/templates/analytics.swift.hbs', - context - ), - client.generateFile( - 'RudderTyperUtils.swift', - 'generators/swift/templates/RudderTyperUtils.swift.hbs', - context - ), - client.generateFile( - 'RudderTyperSerializable.swift', - 'generators/swift/templates/RudderTyperSerializable.swift.hbs', - context - ), - ...context.objects.map(o => - client.generateFile(`${o.name}.swift`, 'generators/swift/templates/class.swift.hbs', o) - ), - ]) - }, -} + return { property, object }; + }, + generateUnion: async (client, schema, _, parentPath) => { + // TODO: support unions in iOS + return defaultPropertyContext(client, schema, 'Any', parentPath, true); + }, + generateTrackCall: async (_client, _schema, functionName) => ({ + functionName: functionName, + }), + generateRoot: async (client, context) => { + await Promise.all([ + client.generateFile( + 'RudderTyperAnalytics.swift', + 'generators/swift/templates/analytics.swift.hbs', + context, + ), + client.generateFile( + 'RudderTyperUtils.swift', + 'generators/swift/templates/RudderTyperUtils.swift.hbs', + context, + ), + client.generateFile( + 'RudderTyperSerializable.swift', + 'generators/swift/templates/RudderTyperSerializable.swift.hbs', + context, + ), + ...context.objects.map(o => + client.generateFile(`${o.name}.swift`, 'generators/swift/templates/class.swift.hbs', o), + ), + ]); + }, +}; function defaultPropertyContext( - client: GeneratorClient, - schema: Schema, - type: string, - namespace: string, - isPointerType: boolean + client: GeneratorClient, + schema: Schema, + type: string, + namespace: string, + isPointerType: boolean, ): SwiftPropertyContext { - return { - name: client.namer.register(schema.name, namespace, { - transform: camelCase, - }), - type, - isVariableNullable: !schema.isRequired || !!schema.isNullable, - isPayloadFieldNullable: !!schema.isNullable && !!schema.isRequired, - isPointerType, - } + return { + name: client.namer.register(schema.name, namespace, { + transform: camelCase, + }), + type, + isVariableNullable: !schema.isRequired || !!schema.isNullable, + isPayloadFieldNullable: !!schema.isNullable && !!schema.isRequired, + isPointerType, + }; } // Handlebars partials function generateFunctionSignature( - functionName: string, - properties: (BasePropertyContext & SwiftPropertyContext)[], - withOptions: boolean + functionName: string, + properties: (BasePropertyContext & SwiftPropertyContext)[], + withOptions: boolean, ): string { - let signature = functionName - const parameters: { - type: string - name: string - isPointerType: boolean - isVariableNullable: boolean - }[] = [...properties] - if (withOptions) { - parameters.push({ - name: 'options', - type: 'RSOption', - isPointerType: true, - isVariableNullable: true, - }) - } + let signature = functionName; + const parameters: { + type: string; + name: string; + isPointerType: boolean; + isVariableNullable: boolean; + }[] = [...properties]; + if (withOptions) { + parameters.push({ + name: 'options', + type: 'RSOption', + isPointerType: true, + isVariableNullable: true, + }); + } - const withNullability = (property: { - type: string - isPointerType: boolean - isVariableNullable: boolean - }) => { - const { type, isVariableNullable } = property - if (isVariableNullable) { - return `${type}? = nil` - } else { - return `${type}` - } - } + const withNullability = (property: { + type: string; + isPointerType: boolean; + isVariableNullable: boolean; + }) => { + const { type, isVariableNullable } = property; + if (isVariableNullable) { + return `${type}? = nil`; + } else { + return `${type}`; + } + }; - signature += `(` - for (let index = 0; index < parameters.length; index++) { - const parameter = parameters[index] - signature += `${parameter.name}: ${withNullability(parameter)}` - if (index != parameters.length - 1) { - signature += `, ` - } - } - signature += `)` + signature += `(`; + for (let index = 0; index < parameters.length; index++) { + const parameter = parameters[index]; + signature += `${parameter.name}: ${withNullability(parameter)}`; + if (index != parameters.length - 1) { + signature += `, `; + } + } + signature += `)`; - return signature.trim() + return signature.trim(); } function generateFunctionCall( - caller: string, - functionName: string, - properties: (BasePropertyContext & SwiftPropertyContext)[], - extraParameterName?: string, - extraParameterValue?: string + caller: string, + functionName: string, + properties: (BasePropertyContext & SwiftPropertyContext)[], + extraParameterName?: string, + extraParameterValue?: string, ): string { - let functionCall = functionName - const parameters: { name: string; value: string }[] = properties.map(p => ({ - name: p.name, - value: p.name, - })) - if (extraParameterName && extraParameterValue) { - parameters.push({ - name: extraParameterName, - value: extraParameterValue, - }) - } + let functionCall = functionName; + const parameters: { name: string; value: string }[] = properties.map(p => ({ + name: p.name, + value: p.name, + })); + if (extraParameterName && extraParameterValue) { + parameters.push({ + name: extraParameterName, + value: extraParameterValue, + }); + } - functionCall += `(` - for (let index = 0; index < parameters.length; index++) { - const parameter = parameters[index] - functionCall += `${parameter.name}: ${parameter.value}` - if (index != parameters.length - 1) { - functionCall += `, ` - } - } - functionCall += `)` + functionCall += `(`; + for (let index = 0; index < parameters.length; index++) { + const parameter = parameters[index]; + functionCall += `${parameter.name}: ${parameter.value}`; + if (index != parameters.length - 1) { + functionCall += `, `; + } + } + functionCall += `)`; - return `${caller}.${functionCall.trim()}` + return `${caller}.${functionCall.trim()}`; } function generatePropertiesDictionary( - properties: (BasePropertyContext & SwiftPropertyContext)[], - prefix?: string + properties: (BasePropertyContext & SwiftPropertyContext)[], + prefix?: string, ): string { - const varOrLet = properties.length > 0 ? `var` : `let` - let out = varOrLet + ` properties = [String: Any]()\n` + const varOrLet = properties.length > 0 ? `var` : `let`; + let out = varOrLet + ` properties = [String: Any]()\n`; - for (const property of properties) { - const name = prefix && prefix.length > 0 ? `${prefix}${property.name}` : property.name - const serializableName = - property.schemaType === Type.BOOLEAN - ? name - : property.schemaType === Type.INTEGER - ? name - : property.schemaType === Type.OBJECT && !property.type.includes('[String: Any]') - ? property.isVariableNullable - ? `${name}?.serializableDictionary()` - : `${name}.serializableDictionary()` - : property.schemaType === Type.ARRAY - ? property.isVariableNullable - ? `${name}?.serializableArray()` - : `${name}.serializableArray()` - : name + for (const property of properties) { + const name = prefix && prefix.length > 0 ? `${prefix}${property.name}` : property.name; + const serializableName = + property.schemaType === Type.BOOLEAN + ? name + : property.schemaType === Type.INTEGER + ? name + : property.schemaType === Type.OBJECT && !property.type.includes('[String: Any]') + ? property.isVariableNullable + ? `${name}?.serializableDictionary()` + : `${name}.serializableDictionary()` + : property.schemaType === Type.ARRAY + ? property.isVariableNullable + ? `${name}?.serializableArray()` + : `${name}.serializableArray()` + : name; - let setter: string - if (property.isPointerType) { - if (property.isPayloadFieldNullable) { - // If the value is nil, we need to convert it from a primitive nil to NSNull (an object). - setter = `properties["${ - property.rawName - }"] = ${name} == nil ? NSNull() : ${serializableName}\n` - } else { - setter = `properties["${property.rawName}"] = ${serializableName};\n` - } - } else { - setter = `properties["${property.rawName}"] = ${serializableName};\n` - } + let setter: string; + if (property.isPointerType) { + if (property.isPayloadFieldNullable) { + // If the value is nil, we need to convert it from a primitive nil to NSNull (an object). + setter = `properties["${property.rawName}"] = ${name} == nil ? NSNull() : ${serializableName}\n`; + } else { + setter = `properties["${property.rawName}"] = ${serializableName};\n`; + } + } else { + setter = `properties["${property.rawName}"] = ${serializableName};\n`; + } - out += setter - } + out += setter; + } - return out + return out; } diff --git a/src/templates.ts b/src/templates.ts index 148b9807..ec7522fe 100644 --- a/src/templates.ts +++ b/src/templates.ts @@ -1,9 +1,9 @@ -import * as fs from 'fs' -import * as Handlebars from 'handlebars' -import { promisify } from 'util' -import { resolve } from 'path' +import * as fs from 'fs'; +import * as Handlebars from 'handlebars'; +import { promisify } from 'util'; +import { resolve } from 'path'; -const readFile = promisify(fs.readFile) +const readFile = promisify(fs.readFile); /** * Header used to mark generated files that are safe to remove during generation. @@ -15,58 +15,58 @@ const readFile = promisify(fs.readFile) * versions of this header when identifying files safe to remove. */ export const RUDDER_AUTOGENERATED_FILE_WARNING = - 'This client was automatically generated by RudderTyper. ** Do Not Edit **' + 'This client was automatically generated by RudderTyper. ** Do Not Edit **'; // Renders a string generated from a template using the provided context. // The template path is relative to the `src` directory. export async function generateFromTemplate>( - templatePath: string, - context: Context, - needsWarning?: boolean + templatePath: string, + context: Context, + needsWarning?: boolean, ): Promise { - const path = resolve(__dirname, templatePath) - const template = await readFile(path, { - encoding: 'utf-8', - }) - const templater = Handlebars.compile(template, { - noEscape: true, - }) + const path = resolve(__dirname, templatePath); + const template = await readFile(path, { + encoding: 'utf-8', + }); + const templater = Handlebars.compile(template, { + noEscape: true, + }); - const content = templater(context) + const content = templater(context); - if (needsWarning && !content.includes(RUDDER_AUTOGENERATED_FILE_WARNING)) { - throw new Error( - `This autogenerated file (${templatePath}) is missing a warning, and therefore will not be cleaned up in future runs.` - ) - } + if (needsWarning && !content.includes(RUDDER_AUTOGENERATED_FILE_WARNING)) { + throw new Error( + `This autogenerated file (${templatePath}) is missing a warning, and therefore will not be cleaned up in future runs.`, + ); + } - return content + return content; } export async function registerPartial(partialPath: string, partialName: string): Promise { - const path = resolve(__dirname, partialPath) - const template = await readFile(path, { - encoding: 'utf-8', - }) - const templater = Handlebars.compile(template, { - noEscape: true, - }) + const path = resolve(__dirname, partialPath); + const template = await readFile(path, { + encoding: 'utf-8', + }); + const templater = Handlebars.compile(template, { + noEscape: true, + }); - Handlebars.registerPartial(partialName, templater) + Handlebars.registerPartial(partialName, templater); } export async function registerStandardHelpers(): Promise { - // Register a helper for indenting multi-line output from other helpers. - Handlebars.registerHelper('indent', (indentation: string, content: string) => { - return content - .split('\n') - .join(`\n${indentation}`) - .trim() - }) - // Register a helper to output a warning that a given file was automatically - // generated by RudderTyper. Note that the exact phrasing is important, since - // it is used to clear generated files. See `clearFolder` in `commands.ts`. - Handlebars.registerHelper('autogeneratedFileWarning', () => { - return RUDDER_AUTOGENERATED_FILE_WARNING - }) + // Register a helper for indenting multi-line output from other helpers. + Handlebars.registerHelper('indent', (indentation: string, content: string) => { + return content + .split('\n') + .join(`\n${indentation}`) + .trim(); + }); + // Register a helper to output a warning that a given file was automatically + // generated by RudderTyper. Note that the exact phrasing is important, since + // it is used to clear generated files. See `clearFolder` in `commands.ts`. + Handlebars.registerHelper('autogeneratedFileWarning', () => { + return RUDDER_AUTOGENERATED_FILE_WARNING; + }); } diff --git a/yarn.lock b/yarn.lock index 714c73cf..482f9881 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5531,7 +5531,7 @@ prettier-linter-helpers@^1.0.0: dependencies: fast-diff "^1.1.2" -prettier@^1.17.0: +prettier@^1.19.1: version "1.19.1" resolved "https://registry.yarnpkg.com/prettier/-/prettier-1.19.1.tgz#f7d7f5ff8a9cd872a7be4ca142095956a60797cb" integrity sha512-s7PoyDv/II1ObgQunCbB9PdLmUcBZcnWOcxDh7O0N/UwDEsHyqkW+Qh28jW+mVuCdx7gLB0BotYI1Y6uI9iyew==