Skip to content

Commit

Permalink
feat: support modelgen without an amplify project being initialized
Browse files Browse the repository at this point in the history
  • Loading branch information
alharris-at committed Sep 12, 2023
1 parent e7ba7f8 commit aa9145e
Show file tree
Hide file tree
Showing 4 changed files with 245 additions and 72 deletions.
283 changes: 213 additions & 70 deletions packages/amplify-codegen/src/commands/models.js
Original file line number Diff line number Diff line change
@@ -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;
}
};
Expand All @@ -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<string | null>} 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<string>} 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<string>} 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,
Expand All @@ -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);
Expand All @@ -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)) {
Expand Down Expand Up @@ -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;
}
Expand Down
23 changes: 23 additions & 0 deletions packages/amplify-codegen/src/utils/getModelSchemaPathParam.js
Original file line number Diff line number Diff line change
@@ -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;
Loading

0 comments on commit aa9145e

Please sign in to comment.