From aa9145e9164427cdacd6709cdae3da02e218bcce Mon Sep 17 00:00:00 2001 From: Al Harris Date: Mon, 11 Sep 2023 17:11:50 -0700 Subject: [PATCH] feat: support modelgen without an amplify project being initialized --- .../amplify-codegen/src/commands/models.js | 283 +++++++++++++----- .../src/utils/getModelSchemaPathParam.js | 23 ++ .../src/utils/getOutputDirParam.js | 9 +- packages/graphql-generator/src/models.ts | 2 +- 4 files changed, 245 insertions(+), 72 deletions(-) create mode 100644 packages/amplify-codegen/src/utils/getModelSchemaPathParam.js diff --git a/packages/amplify-codegen/src/commands/models.js b/packages/amplify-codegen/src/commands/models.js index d7e966209..45bfa220e 100644 --- a/packages/amplify-codegen/src/commands/models.js +++ b/packages/amplify-codegen/src/commands/models.js @@ -1,44 +1,34 @@ const path = require('path'); const fs = require('fs-extra'); -const { parse } = require('graphql'); const glob = require('glob-all'); const { FeatureFlags, pathManager } = require('@aws-amplify/amplify-cli-core'); const { generateModels: generateModelsHelper } = require('@aws-amplify/graphql-generator'); const { validateAmplifyFlutterMinSupportedVersion } = require('../utils/validateAmplifyFlutterMinSupportedVersion'); const defaultDirectiveDefinitions = require('../utils/defaultDirectiveDefinitions'); - -const platformToLanguageMap = { - android: 'java', - ios: 'swift', - flutter: 'dart', - javascript: 'javascript', - typescript: 'typescript', -}; +const getModelSchemaPathParam = require('../utils/getModelSchemaPathParam'); /** * Returns feature flag value, default to `false` - * @param {string} key feature flag id - * @returns + * @param {!string} key feature flag id + * @returns {string} the feature flag value */ -const readFeatureFlag = key => { - let flagValue = false; +const readFeatureFlag = (key) => { try { - flagValue = FeatureFlags.getBoolean(key); - } catch (err) { - flagValue = false; + return FeatureFlags.getBoolean(key); + } catch (_) { + return false; } - return flagValue; }; /** * Returns feature flag value, default to `1` - * @param {string} key feature flag id - * @returns + * @param {!string} key feature flag id + * @returns {number} the feature flag value */ -const readNumericFeatureFlag = key => { +const readNumericFeatureFlag = (key) => { try { return FeatureFlags.getNumber(key); - } catch (err) { + } catch (_) { return 1; } }; @@ -55,71 +45,213 @@ const defaultGenerateModelsOption = { writeToDisk: true, }; -async function generateModels(context, generateOptions = null) { - const { overrideOutputDir, isIntrospection, writeToDisk } = generateOptions - ? { ...defaultGenerateModelsOption, ...generateOptions } - : defaultGenerateModelsOption; - - // steps: - // 1. Load the schema and validate using transformer - // 2. get all the directives supported by transformer - // 3. Generate code - let projectRoot; +/** + * Return the root directory to use for subsequent subdirectories and fs searches. + * @param {*} context the amplify execution context. + * @returns {string} the project root, or current working directory + */ +const getProjectRoot = (context) => { try { + // TODO: It's not clear why we call this `getProjectMeta` call first, so maybe it's side-effecting. This try catch also + // ends up throwing an error over STDOUT "ProjectNotInitializedError: No Amplify backend project files detected within this folder." context.amplify.getProjectMeta(); - projectRoot = context.amplify.getEnvInfo().projectPath; + return context.amplify.getEnvInfo().projectPath; } catch (e) { - projectRoot = process.cwd(); + return process.cwd(); + } +}; + +/** + * Retrieve the path to the model schema (either provided via cli option or in amplify project context). + * @param {*} context the amplify context to search + * @returns {Promise} the model schema path to a .graphql file, directory containing a schema.graphql file, or schema directory full of *.graphql files + */ +const getModelSchemaPath = async (context) => { + const modelSchemaPathParam = getModelSchemaPathParam(context); + if (modelSchemaPathParam) { + return modelSchemaPathParam; } - const allApiResources = await context.amplify.getResourceStatus('api'); - const apiResource = allApiResources.allResources.find( - resource => resource.service === 'AppSync' && resource.providerPlugin === 'awscloudformation', - ); + const apiResource = (await context.amplify.getResourceStatus('api')).allResources + .find(resource => resource.service === 'AppSync' && resource.providerPlugin === 'awscloudformation'); if (!apiResource) { context.print.info('No AppSync API configured. Please add an API'); - return; + return null; } - await validateSchema(context); const backendPath = await context.amplify.pathManager.getBackendDirPath(); - const apiResourcePath = path.join(backendPath, 'api', apiResource.resourceName); + return path.join(backendPath, 'api', apiResource.resourceName); +}; - let directiveDefinitions; +/** + * Retrieve the directives and definitions necessary to make the user-defined model schema valid. + * @param {*} context the amplify project context + * @param {!string} modelSchemaPath the schema path to use for directive retrieval. + * @returns {Promise} the directives to append to a model schema to produce a valid SDL Graphql document. + */ +const getDirectives = async (context, modelSchemaPath) => { try { - directiveDefinitions = await context.amplify.executeProviderUtils(context, 'awscloudformation', 'getTransformerDirectives', { - resourceDir: apiResourcePath, + // Return await is important here, otherwise the catch won't be respected. + return await context.amplify.executeProviderUtils(context, 'awscloudformation', 'getTransformerDirectives', { + resourceDir: modelSchemaPath, }); } catch { - directiveDefinitions = defaultDirectiveDefinitions; + return defaultDirectiveDefinitions; } +}; - const schemaContent = loadSchema(apiResourcePath); - const baseOutputDir = overrideOutputDir || path.join(projectRoot, getModelOutputPath(context)); - const projectConfig = context.amplify.getProjectConfig(); +/** + * Retrieve the output path to write model assets into. + * @param {*} context the amplify execution context. + * @param {!string} overrideOutputDir a user-provided output directory as override + * @param {!string} projectRoot the project root directory, defaulting to cwd + * @returns {string | null} the output path to apply for the project, or null if one cannot be found. + */ +const getOutputPath = (context, overrideOutputDir, projectRoot) => { + if (overrideOutputDir) { + return overrideOutputDir; + } - if (!isIntrospection && projectConfig.frontend === 'flutter' && !validateAmplifyFlutterMinSupportedVersion(projectRoot)) { - context.print.error(`🚫 Models are not generated! + try { + return path.join(projectRoot, getModelOutputPath(context)); + } catch (_) { + context.print.error('Output model path could not be generated from existing amplify project, use --output-dir to override'); + return null; + } +}; + +/** + * Retrieve the codegen target given a set of input flags, context options, and the project config. + * @param {*} context the amplify execution context + * @param {!boolean} isIntrospection whether or not this is a request for an introspection model. + * @returns {!import('@aws-amplify/appsync-modelgen-plugin').Target} the Modelgen target as a string + */ +const getTarget = (context, isIntrospection) => { + if (isIntrospection) { + return 'introspection'; + } + + const targetParam = context.parameters?.options?.['target']; + if (targetParam) { + return targetParam; + } + + return context.amplify.getProjectConfig().frontend; +}; + +/** + * Try and retrieve a boolean feature flag value from the CLI input. + * @param {*} context the amplify runtime context + * @param {!string} flagName the feature flag name + * @returns {boolean | null} the feature flag value if found, and can be coerced to a boolean, else null + */ +const getOptionBasedFeatureFlag = (context, flagName) => { + const featureFlagParamName = `feature-flag:${flagName}`; + const paramNames = context.parameters?.options ? new Set(Object.keys(context.parameters?.options)) : new Set(); + if (!paramNames.has(featureFlagParamName)) { + return null; + } + + const optionValue = context.parameters?.options?.[featureFlagParamName]; + if (optionValue === 'true' || optionValue === 'True' || optionValue === true) { + return true; + } + if (optionValue === 'false' || optionValue === 'False' || optionValue === false) { + return false; + } + return null; +}; + +/** + * Try and retrieve an integer feature flag value from the CLI input. + * @param {*} context the amplify runtime context + * @param {!string} flagName the feature flag name + * @returns {number | null} the feature flag value if found, and can be coerced to an int, else null + */ +const getOptionBasedNumericFeatureFlag = (context, flagName) => { + const featureFlagParamName = `feature-flag:${flagName}`; + const paramNames = context.parameters?.options ? new Set(Object.keys(context.parameters?.options)) : new Set(); + if (!paramNames.has(featureFlagParamName)) { + return null; + } + + const optionValue = context.parameters?.options?.[featureFlagParamName]; + return Number.parseInt(optionValue, 10); +}; + +/** + * Retrieve the feature flags either using sane defaults, or an amplify feature flags file. + * @param {*} context the amplify runtime context + * @returns {({ generateIndexRules: boolean, emitAuthProvider: boolean, useExperimentalPipelinedTransformer: boolean, transformerVersion: number, respectPrimaryKeyAttributesOnConnectionField: boolean, generateModelsForLazyLoadAndCustomSelectionSet: boolean, improvePluralization: boolean, addTimestampFields: boolean, handleListNullabilityTransparently: boolean })} the feature flags to provide to the generator + */ +const getFeatureFlags = (context) => { + return { + generateIndexRules: getOptionBasedFeatureFlag(context, 'codegen.generateIndexRules') ?? readFeatureFlag('codegen.generateIndexRules'), + emitAuthProvider: getOptionBasedFeatureFlag(context, 'codegen.emitAuthProvider') ?? readFeatureFlag('codegen.emitAuthProvider'), + useExperimentalPipelinedTransformer: getOptionBasedFeatureFlag(context, 'graphQLTransformer.useExperimentalPipelinedTransformer') ?? readFeatureFlag('graphQLTransformer.useExperimentalPipelinedTransformer'), + transformerVersion: getOptionBasedNumericFeatureFlag(context, 'graphQLTransformer.transformerVersion') ?? readNumericFeatureFlag('graphQLTransformer.transformerVersion'), + respectPrimaryKeyAttributesOnConnectionField: getOptionBasedFeatureFlag(context, 'graphQLTransformer.respectPrimaryKeyAttributesOnConnectionField') ?? readFeatureFlag('graphQLTransformer.respectPrimaryKeyAttributesOnConnectionField'), + generateModelsForLazyLoadAndCustomSelectionSet: getOptionBasedFeatureFlag(context, 'codegen.generateModelsForLazyLoadAndCustomSelectionSet') ?? readFeatureFlag('codegen.generateModelsForLazyLoadAndCustomSelectionSet'), + improvePluralization: getOptionBasedFeatureFlag(context, 'graphQLTransformer.improvePluralization') ?? readFeatureFlag('graphQLTransformer.improvePluralization'), + addTimestampFields: getOptionBasedFeatureFlag(context, 'codegen.addTimestampFields') ?? readFeatureFlag('codegen.addTimestampFields'), + handleListNullabilityTransparently: getOptionBasedFeatureFlag(context, 'codegen.handleListNullabilityTransparently') ?? readFeatureFlag('codegen.handleListNullabilityTransparently'), + }; +}; + +/** + * Run validations over the project state, return the errors of an array of strings. + * @param {!string} projectRoot the project root path for validating local state + * @param {!import('@aws-amplify/appsync-modelgen-plugin').Target} target the runtime target we are running modelgen for. + * @returns {Array} the list of validation failures detected + */ +const validateProjectState = (projectRoot, target) => { + const validationFailures = []; + if (target === 'flutter' && !validateAmplifyFlutterMinSupportedVersion(projectRoot)) { + validationFailures.push(`🚫 Models are not generated! Amplify Flutter versions prior to 0.6.0 are no longer supported by codegen. Please upgrade to use codegen.`); + } + return validationFailures; +}; + +async function generateModels(context, generateOptions = null) { + const { overrideOutputDir, isIntrospection, writeToDisk } = generateOptions + ? { ...defaultGenerateModelsOption, ...generateOptions } + : defaultGenerateModelsOption; + + const projectRoot = getProjectRoot(context); + const modelSchemaPath = await getModelSchemaPath(context); + if (!modelSchemaPath) { return; } + const directives = await getDirectives(context, modelSchemaPath); - const generateIndexRules = readFeatureFlag('codegen.generateIndexRules'); - const emitAuthProvider = readFeatureFlag('codegen.emitAuthProvider'); - const useExperimentalPipelinedTransformer = readFeatureFlag('graphQLTransformer.useExperimentalPipelinedTransformer'); - const transformerVersion = readNumericFeatureFlag('graphQLTransformer.transformerVersion'); - const respectPrimaryKeyAttributesOnConnectionField = readFeatureFlag('graphQLTransformer.respectPrimaryKeyAttributesOnConnectionField'); - const generateModelsForLazyLoadAndCustomSelectionSet = readFeatureFlag('codegen.generateModelsForLazyLoadAndCustomSelectionSet'); - const improvePluralization = readFeatureFlag('graphQLTransformer.improvePluralization'); - const addTimestampFields = readFeatureFlag('codegen.addTimestampFields'); + await validateSchema(context); + const schema = loadSchema(modelSchemaPath); + const target = getTarget(context, isIntrospection); - const handleListNullabilityTransparently = readFeatureFlag('codegen.handleListNullabilityTransparently'); + const validationFailures = validateProjectState(projectRoot, target); + if (validationFailures.length > 0) { + validationFailures.forEach((validationFailure) => context.print.error(validationFailure)); + return; + } + + const { + generateIndexRules, + emitAuthProvider, + useExperimentalPipelinedTransformer, + transformerVersion, + respectPrimaryKeyAttributesOnConnectionField, + generateModelsForLazyLoadAndCustomSelectionSet, + improvePluralization, + addTimestampFields, + handleListNullabilityTransparently, + } = getFeatureFlags(context); const generatedCode = await generateModelsHelper({ - schema: schemaContent, - directives: directiveDefinitions, - target: isIntrospection ? 'introspection' : platformToLanguageMap[projectConfig.frontend], + schema, + directives, + target, generateIndexRules, emitAuthProvider, useExperimentalPipelinedTransformer, @@ -132,14 +264,20 @@ Amplify Flutter versions prior to 0.6.0 are no longer supported by codegen. Plea }); if (writeToDisk) { + const outputPath = getOutputPath(context, overrideOutputDir, projectRoot); + if (!outputPath) { + return; + } Object.entries(generatedCode).forEach(([filepath, contents]) => { - fs.outputFileSync(path.resolve(path.join(baseOutputDir, filepath)), contents); + fs.outputFileSync(path.resolve(path.join(outputPath, filepath)), contents); }); - // TODO: move to @aws-amplify/graphql-generator - generateEslintIgnore(context); + try { + // TODO: move to @aws-amplify/graphql-generator + generateEslintIgnore(context, target); + } catch (e) {} - context.print.info(`Successfully generated models. Generated models can be found in ${baseOutputDir}`); + context.print.info(`Successfully generated models. Generated models can be found in ${outputPath}`); } return Object.values(generatedCode); @@ -158,7 +296,15 @@ async function validateSchema(context) { } } +/** + * Given a path, return the graphql file contents (or concatenated contents) + * @param {*} apiResourcePath the path to a graphql file or directory of graphql files. + * @returns the graphql file contents + */ function loadSchema(apiResourcePath) { + if (fs.lstatSync(apiResourcePath).isFile()) { + return fs.readFileSync(apiResourcePath, 'utf8'); + } const schemaFilePath = path.join(apiResourcePath, 'schema.graphql'); const schemaDirectory = path.join(apiResourcePath, 'schema'); if (fs.pathExistsSync(schemaFilePath)) { @@ -196,15 +342,12 @@ function getModelOutputPath(context) { } } -function generateEslintIgnore(context) { - const projectConfig = context.amplify.getProjectConfig(); - - if (projectConfig.frontend !== 'javascript') { +const generateEslintIgnore = (context, target) => { + if (target !== 'javascript') { return; } const projectPath = pathManager.findProjectRoot(); - if (!projectPath) { return; } diff --git a/packages/amplify-codegen/src/utils/getModelSchemaPathParam.js b/packages/amplify-codegen/src/utils/getModelSchemaPathParam.js new file mode 100644 index 000000000..0ca854970 --- /dev/null +++ b/packages/amplify-codegen/src/utils/getModelSchemaPathParam.js @@ -0,0 +1,23 @@ +const path = require('path'); + +/** + * Retrieve the specified model schema path parameter, returning as an absolute path. + * @param context the CLI invocation context + * @returns the absolute path to the model schema path + */ +function getModelSchemaPathParam(context) { + const modelSchemaPathParam = context.parameters?.options?.['model-schema']; + if ( !modelSchemaPathParam ) { + return null; + } + let projectRoot; + try { + context.amplify.getProjectMeta(); + projectRoot = context.amplify.getEnvInfo().projectPath; + } catch (e) { + projectRoot = process.cwd(); + } + return path.isAbsolute(modelSchemaPathParam) ? modelSchemaPathParam : path.join(projectRoot, modelSchemaPathParam); +} + +module.exports = getModelSchemaPathParam; diff --git a/packages/amplify-codegen/src/utils/getOutputDirParam.js b/packages/amplify-codegen/src/utils/getOutputDirParam.js index 3f9f23e2f..ec4fd8495 100644 --- a/packages/amplify-codegen/src/utils/getOutputDirParam.js +++ b/packages/amplify-codegen/src/utils/getOutputDirParam.js @@ -15,7 +15,14 @@ function getOutputDirParam(context, isRequired) { if ( !outputDirParam ) { return null; } - return path.isAbsolute(outputDirParam) ? outputDirParam : path.join(context.amplify.getEnvInfo().projectPath, outputDirParam); + let projectRoot; + try { + context.amplify.getProjectMeta(); + projectRoot = context.amplify.getEnvInfo().projectPath; + } catch (e) { + projectRoot = process.cwd(); + } + return path.isAbsolute(outputDirParam) ? outputDirParam : path.join(projectRoot, outputDirParam); } module.exports = getOutputDirParam; diff --git a/packages/graphql-generator/src/models.ts b/packages/graphql-generator/src/models.ts index 3dfb490ed..e79d6b795 100644 --- a/packages/graphql-generator/src/models.ts +++ b/packages/graphql-generator/src/models.ts @@ -1,7 +1,7 @@ import { parse } from 'graphql'; import * as appSyncDataStoreCodeGen from '@aws-amplify/appsync-modelgen-plugin'; import { codegen } from '@graphql-codegen/core'; -import { ModelsTarget, GenerateModelsOptions, GeneratedOutput } from './typescript'; +import { GenerateModelsOptions, GeneratedOutput } from './typescript'; const { version: packageVersion } = require('../package.json'); export async function generateModels(options: GenerateModelsOptions): Promise {