diff --git a/README.md b/README.md index 90250733..073b580f 100644 --- a/README.md +++ b/README.md @@ -108,7 +108,7 @@ $ npm install -g @devcycle/cli $ dvc COMMAND running command... $ dvc (--version) -@devcycle/cli/5.18.0 linux-x64 node-v20.10.0 +@devcycle/cli/5.18.0 darwin-arm64 node-v20.10.0 $ dvc --help [COMMAND] USAGE $ dvc COMMAND diff --git a/docs/generate.md b/docs/generate.md index f4efe3f7..b4ff792b 100644 --- a/docs/generate.md +++ b/docs/generate.md @@ -13,7 +13,8 @@ Generate Variable Types from the management API USAGE $ dvc generate types [--config-path ] [--auth-path ] [--repo-config-path ] [--client-id ] [--client-secret ] [--project ] [--no-api] [--headless] [--output-dir ] [--react] - [--nextjs] [--old-repos] [--include-descriptions] [--obfuscate] [--include-deprecation-warnings] + [--nextjs] [--old-repos] [--include-descriptions] [--strict-custom-data] [--obfuscate] + [--include-deprecation-warnings] FLAGS --include-deprecation-warnings Include @deprecated tags for variables of completed features @@ -24,6 +25,7 @@ FLAGS @devcycle/devcycle-js-sdk) --output-dir= [default: .] Directory to output the generated types to --react Generate types for use with React + --strict-custom-data Generate stricter custom data types GLOBAL FLAGS --auth-path= Override the default location to look for an auth.yml file diff --git a/oclif.manifest.json b/oclif.manifest.json index b9cac76d..654526c0 100644 --- a/oclif.manifest.json +++ b/oclif.manifest.json @@ -1,5 +1,5 @@ { - "version": "5.17.0", + "version": "5.18.0", "commands": { "authCommand": { "id": "authCommand", @@ -2245,6 +2245,12 @@ "description": "Include variable descriptions in the variable information comment", "allowNo": false }, + "strict-custom-data": { + "name": "strict-custom-data", + "type": "boolean", + "description": "Generate stricter custom data types", + "allowNo": false + }, "obfuscate": { "name": "obfuscate", "type": "boolean", diff --git a/src/api/customProperties.ts b/src/api/customProperties.ts new file mode 100644 index 00000000..cfff67ca --- /dev/null +++ b/src/api/customProperties.ts @@ -0,0 +1,13 @@ +import apiClient from './apiClient' +import { buildHeaders } from './common' + +const BASE_URL = '/v1/projects/:project/customProperties' + +export const fetchCustomProperties = async (token: string, project_id: string) => { + return apiClient.get(`${BASE_URL}`, { + headers: buildHeaders(token), + params: { + project: project_id, + }, + }) +} diff --git a/src/api/schemas.ts b/src/api/schemas.ts index 19abd0ec..60abee4f 100644 --- a/src/api/schemas.ts +++ b/src/api/schemas.ts @@ -11,7 +11,7 @@ export type FeatureConfig = z.infer export type Audience = z.infer export type Target = z.infer export type Override = z.infer - +export type CustomProperty = z.infer export type CreateEnvironmentParams = z.infer< typeof schemas.CreateEnvironmentDto > diff --git a/src/api/zodClient.ts b/src/api/zodClient.ts index 21a49b50..46c4fc3f 100644 --- a/src/api/zodClient.ts +++ b/src/api/zodClient.ts @@ -650,6 +650,23 @@ const CustomProperty = z.object({ _project: z.string(), _createdBy: z.string(), propertyKey: z.string(), + schema: z + .object({ + schemaType: z.enum(['enum']), + required: z.boolean().optional(), + enumSchema: z + .object({ + allowedValues: z.array( + z.object({ + label: z.string(), + value: z.union([z.string(), z.number()]), + }), + ), + allowAdditionalValues: z.boolean().optional(), + }) + .optional(), + }) + .optional(), type: z.enum(['String', 'Boolean', 'Number']), createdAt: z.string().datetime(), updatedAt: z.string().datetime(), diff --git a/src/commands/generate/__snapshots__/types.test.ts.snap b/src/commands/generate/__snapshots__/types.test.ts.snap index 3f0e8e05..83eea14b 100644 --- a/src/commands/generate/__snapshots__/types.test.ts.snap +++ b/src/commands/generate/__snapshots__/types.test.ts.snap @@ -1,8 +1,150 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`generate types correctly generates JS SDK types 1`] = ` +exports[`generate types correctly generates JS SDK types with custom data type 1`] = ` "import { DevCycleJSON } from '@devcycle/js-client-sdk' +export type CustomData = { + 'customString': string + 'customEnum'?: | + // Option 1 + 'option1' | + // Option 2 + 'option2' + 'numberEnum'?: | + // Option 1 + 1 | + // Option 2 + 2 | + number + 'regularNumber'?: number +} +export type DVCVariableTypes = { + /** + * key: enum-var + * description: Different ways to say hello + * created by: User 1 + * created on: 2021-07-04 + */ + 'enum-var': 'Hello' | 'Hey' | 'Hi' + /** + * key: regex-var + * created by: User 2 + * created on: 2021-07-04 + */ + 'regex-var': string + /** + * key: string-var + * created by: User 1 + * created on: 2021-07-04 + */ + 'string-var': string + /** + * key: boolean-var + * created by: User 2 + * created on: 2021-07-04 + */ + 'boolean-var': boolean + /** + * key: number-var + * created by: User 1 + * created on: 2021-07-04 + */ + 'number-var': number + /** + * key: json-var + * created by: Unknown User + * created on: 2021-07-04 + */ + 'json-var': DevCycleJSON + /** + * key: deprecated-var + * created by: Unknown User + * created on: 2021-07-04 + * @deprecated This variable is part of complete feature \\"Completed Feature\\" and should be cleaned up. + + */ + 'deprecated-var': string +} + +/** + * key: enum-var + * description: Different ways to say hello + * created by: User 1 + * created on: 2021-07-04 +*/ + +export const ENUM_VAR = 'enum-var' as const + +/** + * key: regex-var + * created by: User 2 + * created on: 2021-07-04 +*/ + +export const REGEX_VAR = 'regex-var' as const + +/** + * key: string-var + * created by: User 1 + * created on: 2021-07-04 +*/ + +export const STRING_VAR = 'string-var' as const + +/** + * key: boolean-var + * created by: User 2 + * created on: 2021-07-04 +*/ + +export const BOOLEAN_VAR = 'boolean-var' as const + +/** + * key: number-var + * created by: User 1 + * created on: 2021-07-04 +*/ + +export const NUMBER_VAR = 'number-var' as const + +/** + * key: json-var + * created by: Unknown User + * created on: 2021-07-04 +*/ + +export const JSON_VAR = 'json-var' as const + +/** + * key: deprecated-var + * created by: Unknown User + * created on: 2021-07-04 + * @deprecated This variable is part of complete feature \\"Completed Feature\\" and should be cleaned up. + +*/ + +export const DEPRECATED_VAR = 'deprecated-var' as const +" +`; + +exports[`generate types correctly generates JS SDK types with custom data type in strict mode 1`] = ` +"import { DevCycleJSON } from '@devcycle/js-client-sdk' + +export type CustomData = { + 'customString': string + 'customEnum'?: | + // Option 1 + 'option1' | + // Option 2 + 'option2' + 'numberEnum'?: | + // Option 1 + 1 | + // Option 2 + 2 | + number + 'regularNumber'?: number +} export type DVCVariableTypes = { /** * key: enum-var @@ -115,6 +257,9 @@ export const DEPRECATED_VAR = 'deprecated-var' as const exports[`generate types correctly generates JS SDK types with obfuscated keys 1`] = ` "import { DevCycleJSON } from '@devcycle/js-client-sdk' +export type CustomData = { + +} export type DVCVariableTypes = { 'dvc_obfs_8397a7cc0bd9847dab4cfcffc90d01385e21fec6f1d3999e3aaac2a137d69964': 'Hello' | 'Hey' | 'Hi' 'dvc_obfs_cabd691e35fffe317483d72108898be69e03c7611dfb035660e4f71b9ed8cc44': string @@ -188,6 +333,21 @@ exports[`generate types correctly generates Next.js SDK types 1`] = ` DevCycleJSON } from '@devcycle/nextjs-sdk' +export type CustomData = { + 'customString': string + 'customEnum'?: | + // Option 1 + 'option1' | + // Option 2 + 'option2' + 'numberEnum'?: | + // Option 1 + 1 | + // Option 2 + 2 | + number + 'regularNumber'?: number +} export type UseVariableValue = < K extends string & keyof DVCVariableTypes @@ -326,6 +486,21 @@ exports[`generate types correctly generates React SDK types 1`] = ` DevCycleJSON } from '@devcycle/react-client-sdk' +export type CustomData = { + 'customString': string + 'customEnum'?: | + // Option 1 + 'option1' | + // Option 2 + 'option2' + 'numberEnum'?: | + // Option 1 + 1 | + // Option 2 + 2 | + number + 'regularNumber'?: number +} export type UseVariableValue = < K extends string & keyof DVCVariableTypes diff --git a/src/commands/generate/types.test.ts b/src/commands/generate/types.test.ts index cdfc6133..a61a6e3d 100644 --- a/src/commands/generate/types.test.ts +++ b/src/commands/generate/types.test.ts @@ -92,6 +92,50 @@ const mockOrganizationMembersResponseHeaders = { count: 2, } as unknown as ReplyHeaders +const mockCustomPropertiesResponse = [ + { + _id: '123456789', + propertyKey: 'customString', + type: 'String', + schema: { required: true }, + }, + { + _id: '987654321', + propertyKey: 'customEnum', + type: 'String', + schema: { + required: false, + enumSchema: { + allowedValues: [ + { label: 'Option 1', value: 'option1' }, + { label: 'Option 2', value: 'option2' }, + ], + allowAdditionalValues: false, + }, + }, + }, + { + _id: '987654321', + propertyKey: 'numberEnum', + type: 'Number', + schema: { + required: false, + enumSchema: { + allowedValues: [ + { label: 'Option 1', value: 1 }, + { label: 'Option 2', value: 2 }, + ], + allowAdditionalValues: true, + }, + }, + }, + { + _id: '987654321', + propertyKey: 'regularNumber', + type: 'Number', + }, +] + const artifactsDir = './test/artifacts/' const jsOutputDir = artifactsDir + 'generate/js' const reactOutputDir = artifactsDir + 'generate/react' @@ -114,7 +158,7 @@ const mockCompletedArchivedFeaturesResponse = [ ] as Body // Add this function at the top of the file, after the imports -const setupNockMock = (api: Nock.Scope) => { +const setupNockMock = (customProperties: unknown[]) => (api: Nock.Scope) => { return api .get('/v1/projects/project/variables?perPage=1000&page=1&status=active') .reply(200, mockVariablesResponse) @@ -122,13 +166,19 @@ const setupNockMock = (api: Nock.Scope) => { .reply( 200, mockOrganizationMembersResponse, - mockOrganizationMembersResponseHeaders + mockOrganizationMembersResponseHeaders, + ) + .get( + '/v1/projects/project/features?perPage=1000&page=1&status=complete', ) - .get('/v1/projects/project/features?perPage=1000&page=1&status=complete') .reply(200, mockCompletedArchivedFeaturesResponse) - .get('/v1/projects/project/features?perPage=1000&page=1&status=archived') - .reply(200, mockCompletedArchivedFeaturesResponse); -}; + .get( + '/v1/projects/project/features?perPage=1000&page=1&status=archived', + ) + .reply(200, mockCompletedArchivedFeaturesResponse) + .get('/v1/projects/project/customProperties') + .reply(200, customProperties) +} describe('generate types', () => { beforeEach(setCurrentTestFile(__filename)) @@ -139,7 +189,7 @@ describe('generate types', () => { }) dvcTest() - .nock(BASE_URL, setupNockMock) + .nock(BASE_URL, setupNockMock(mockCustomPropertiesResponse)) .stdout() .command([ 'generate:types', @@ -152,7 +202,7 @@ describe('generate types', () => { '--project', 'project', ]) - .it('correctly generates JS SDK types', (ctx) => { + .it('correctly generates JS SDK types with custom data type', (ctx) => { const outputDir = jsOutputDir + '/dvcVariableTypes.ts' expect(ctx.stdout).to.contain(`Generated new types to ${outputDir}`) expect(fs.existsSync(outputDir)).to.be.true @@ -161,7 +211,35 @@ describe('generate types', () => { }) dvcTest() - .nock(BASE_URL, setupNockMock) + .nock(BASE_URL, setupNockMock(mockCustomPropertiesResponse)) + .stdout() + .command([ + 'generate:types', + '--output-dir', + jsOutputDir, + '--client-id', + 'client', + '--client-secret', + 'secret', + '--project', + 'project', + '--strict-custom-data', + ]) + .it( + 'correctly generates JS SDK types with custom data type in strict mode', + (ctx) => { + const outputDir = jsOutputDir + '/dvcVariableTypes.ts' + expect(ctx.stdout).to.contain( + `Generated new types to ${outputDir}`, + ) + expect(fs.existsSync(outputDir)).to.be.true + const typesString = fs.readFileSync(outputDir, 'utf-8') + expect(typesString).toMatchSnapshot() + }, + ) + + dvcTest() + .nock(BASE_URL, setupNockMock(mockCustomPropertiesResponse)) .stdout() .command([ 'generate:types', @@ -184,7 +262,7 @@ describe('generate types', () => { }) dvcTest() - .nock(BASE_URL, setupNockMock) + .nock(BASE_URL, setupNockMock(mockCustomPropertiesResponse)) .stdout() .command([ 'generate:types', @@ -207,7 +285,7 @@ describe('generate types', () => { }) dvcTest() - .nock(BASE_URL, setupNockMock) + .nock(BASE_URL, setupNockMock([])) .stdout() .command([ 'generate:types', @@ -237,7 +315,7 @@ describe('generate types', () => { ) dvcTest() - .nock(BASE_URL, setupNockMock) + .nock(BASE_URL, setupNockMock([])) .stdout() .command([ 'generate:types', diff --git a/src/commands/generate/types.ts b/src/commands/generate/types.ts index f6c0c4f8..4379b701 100644 --- a/src/commands/generate/types.ts +++ b/src/commands/generate/types.ts @@ -2,14 +2,15 @@ import Base from '../base' import fs from 'fs' import { fetchAllVariables } from '../../api/variables' import { Flags } from '@oclif/core' -import { Feature, Project, Variable } from '../../api/schemas' +import { Feature, Project, Variable, CustomProperty } from '../../api/schemas' import { OrganizationMember, fetchOrganizationMembers } from '../../api/members' import { upperCase } from 'lodash' import { createHash } from 'crypto' import path from 'path' import { fetchAllCompletedOrArchivedFeatures } from '../../api/features' +import { fetchCustomProperties } from '../../api/customProperties' -const reactImports = (oldRepos: boolean) => { +const reactImports = (oldRepos: boolean, strictCustomData: boolean) => { if (oldRepos) { return `import { DVCVariable, DVCVariableValue } from '@devcycle/devcycle-js-sdk' import { @@ -25,7 +26,7 @@ export type DevCycleJSON = { [key: string]: string | boolean | number } useVariable as originalUseVariable, useVariableValue as originalUseVariableValue, DVCVariable, - DVCVariableValue, + DVCVariableValue,${!strictCustomData ? '\n DVCCustomDataJSON,' : ''} DevCycleJSON } from '@devcycle/react-client-sdk' @@ -33,12 +34,12 @@ export type DevCycleJSON = { [key: string]: string | boolean | number } } } -const nextImports = () => { +const nextImports = (strictCustomData: boolean) => { return `import { useVariable as originalUseVariable, useVariableValue as originalUseVariableValue, DVCVariable, - DVCVariableValue, + DVCVariableValue,${!strictCustomData ? '\n DVCCustomDataJSON,' : ''} DevCycleJSON } from '@devcycle/nextjs-sdk' @@ -101,12 +102,17 @@ export default class GenerateTypes extends Base { 'Include variable descriptions in the variable information comment', default: true, }), + 'strict-custom-data': Flags.boolean({ + description: 'Generate stricter custom data types', + default: true, + }), obfuscate: Flags.boolean({ description: 'Obfuscate the variable keys.', default: false, }), 'include-deprecation-warnings': Flags.boolean({ - description: 'Include @deprecated tags for variables of completed features', + description: + 'Include @deprecated tags for variables of completed features', default: true, }), } @@ -119,6 +125,7 @@ export default class GenerateTypes extends Base { outputDir: string includeDeprecationWarnings = true features: Feature[] = [] + customProperties: CustomProperty[] public async run(): Promise { const { flags } = await this.parse(GenerateTypes) @@ -135,6 +142,10 @@ export default class GenerateTypes extends Base { this.outputDir = outputDir this.includeDeprecationWarnings = includeDeprecationWarnings this.project = await this.requireProject(project, headless) + this.customProperties = await fetchCustomProperties( + this.authToken, + this.projectKey, + ) if (this.project.settings.obfuscation.required) { if (!this.obfuscate) { @@ -151,7 +162,10 @@ export default class GenerateTypes extends Base { ) } - this.features = await fetchAllCompletedOrArchivedFeatures(this.authToken, this.projectKey) + this.features = await fetchAllCompletedOrArchivedFeatures( + this.authToken, + this.projectKey, + ) const variables = await fetchAllVariables( this.authToken, @@ -163,6 +177,7 @@ export default class GenerateTypes extends Base { flags['react'], flags['nextjs'], flags['old-repos'], + flags['strict-custom-data'], ) try { @@ -192,6 +207,7 @@ export default class GenerateTypes extends Base { react: boolean, next: boolean, oldRepos: boolean, + strictCustomData: boolean, ) { const typeLines = variables.map((variable) => this.getTypeDefinitionLine(variable), @@ -202,18 +218,19 @@ export default class GenerateTypes extends Base { let imports = '' if (react) { - imports = reactImports(oldRepos) + imports = reactImports(oldRepos, strictCustomData) } else if (next) { - imports = nextImports() + imports = nextImports(strictCustomData) } else { // Add a default import for non-React, non-Next.js cases imports = oldRepos ? `export type DevCycleJSON = { [key: string]: string | boolean | number }\n\n` - : `import { DevCycleJSON } from '@devcycle/js-client-sdk'\n\n` + : `import { DevCycleJSON${!strictCustomData ? ', DVCCustomDataJSON' : ''} } from '@devcycle/js-client-sdk'\n\n` } let types = imports + + generateCustomDataType(this.customProperties, strictCustomData) + (react || next ? reactOverrides : '') + 'export type DVCVariableTypes = {\n' + typeLines.join('\n') + @@ -240,9 +257,10 @@ export default class GenerateTypes extends Base { } private getVariableInfoComment(variable: Variable, indent: boolean) { - const descriptionText = this.includeDescriptions && variable.description - ? `${sanitizeDescription(variable.description)}` - : '' + const descriptionText = + this.includeDescriptions && variable.description + ? `${sanitizeDescription(variable.description)}` + : '' const creator = variable._createdBy ? findCreatorName(this.orgMembers, variable._createdBy) @@ -250,8 +268,9 @@ export default class GenerateTypes extends Base { const createdDate = variable.createdAt.split('T')[0] const deprecationInfo = isVariableDeprecated(variable, this.features) - const isDeprecated = this.includeDeprecationWarnings && deprecationInfo.deprecated - const deprecationWarning = isDeprecated + const isDeprecated = + this.includeDeprecationWarnings && deprecationInfo.deprecated + const deprecationWarning = isDeprecated ? `@deprecated This variable is part of ${deprecationInfo.feature?.status} feature "${deprecationInfo.feature?.name}" and should be cleaned up.\n` : '' @@ -261,7 +280,7 @@ export default class GenerateTypes extends Base { createdDate, indent, !this.obfuscate ? variable.key : undefined, - deprecationWarning + deprecationWarning, ) } @@ -339,7 +358,7 @@ export const blockComment = ( createdDate: string, indent: boolean, key?: string, - deprecationWarning?: string + deprecationWarning?: string, ) => { const indentString = indent ? ' ' : '' return ( @@ -351,7 +370,9 @@ export const blockComment = ( : '') + `${indentString} * created by: ${creator}\n` + `${indentString} * created on: ${createdDate}\n` + - (deprecationWarning ? `${indentString} * ${deprecationWarning}\n` : '') + + (deprecationWarning + ? `${indentString} * ${deprecationWarning}\n` + : '') + indentString + '*/' ) @@ -378,7 +399,36 @@ export function getVariableType(variable: Variable) { } function isVariableDeprecated(variable: Variable, features: Feature[]) { - if (!variable._feature || variable.persistent) return { deprecated: false} - const feature = features.find(f => f._id === variable._feature) - return {deprecated: feature && feature.status !== 'active', feature} + if (!variable._feature || variable.persistent) return { deprecated: false } + const feature = features.find((f) => f._id === variable._feature) + return { deprecated: feature && feature.status !== 'active', feature } +} + +const generateCustomDataType = ( + customProperties: CustomProperty[], + strict: boolean, +) => { + const properties = customProperties + .map((prop) => { + const propType = prop.type.toLowerCase() + const schema = prop.schema?.enumSchema + const isRequired = prop.schema?.required ?? false + const optionalMarker = isRequired ? '' : '?' + if (schema) { + const enumValues = schema.allowedValues + .map(({ label, value }) => { + const valueStr = + typeof value === 'number' ? value : `'${value}'` + return ` // ${label}\n ${valueStr}` + }) + .join(' | \n') + return ` '${prop.propertyKey}'${optionalMarker}: | \n${enumValues}${schema.allowAdditionalValues ? ` |\n ${propType}` : ''}` + } + return ` '${prop.propertyKey}'${optionalMarker}: ${propType}` + }) + .join('\n') + + return `export type CustomData = { +${properties} +}${!strict ? ' & DVCCustomDataJSON' : ''}\n` }