Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add types to graphql in TS outputs #620

Closed
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 13 additions & 8 deletions packages/amplify-codegen/src/commands/statements.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,13 @@ const Ora = require('ora');

const { loadConfig } = require('../codegen-config');
const constants = require('../constants');
const { ensureIntrospectionSchema, getFrontEndHandler, getAppSyncAPIDetails, readSchemaFromFile, GraphQLStatementsFormatter } = require('../utils');
const {
ensureIntrospectionSchema,
getFrontEndHandler,
getAppSyncAPIDetails,
readSchemaFromFile,
GraphQLStatementsFormatter,
} = require('../utils');
const { generateGraphQLDocuments } = require('@aws-amplify/graphql-docs-generator');

async function generateStatements(context, forceDownloadSchema, maxDepth, withoutInit = false, decoupleFrontend = '') {
Expand Down Expand Up @@ -62,15 +68,14 @@ async function generateStatements(context, forceDownloadSchema, maxDepth, withou
const schemaData = readSchemaFromFile(schemaPath);
const generatedOps = generateGraphQLDocuments(schemaData, {
maxDepth: maxDepth || cfg.amplifyExtension.maxDepth,
useExternalFragmentForS3Object: (language === 'graphql'),
useExternalFragmentForS3Object: language === 'graphql',
// default typenameIntrospection to true when not set
typenameIntrospection:
cfg.amplifyExtension.typenameIntrospection === undefined ? true : !!cfg.amplifyExtension.typenameIntrospection,
});
if(!generatedOps) {
if (!generatedOps) {
context.print.warning('No GraphQL statements are generated. Check if the introspection schema has GraphQL operations defined.');
}
else {
} else {
await writeGeneratedDocuments(language, generatedOps, opsGenDirectory);
opsGenSpinner.succeed(constants.INFO_MESSAGE_OPS_GEN_SUCCESS + path.relative(path.resolve('.'), opsGenDirectory));
}
Expand All @@ -86,7 +91,7 @@ async function writeGeneratedDocuments(language, generatedStatements, outputPath
['queries', 'mutations', 'subscriptions'].forEach(op => {
const ops = generatedStatements[op];
if (ops && ops.size) {
const formattedStatements = (new GraphQLStatementsFormatter(language)).format(ops);
const formattedStatements = new GraphQLStatementsFormatter(language, op).format(ops);
const outputFile = path.resolve(path.join(outputPath, `${op}.${fileExtension}`));
fs.writeFileSync(outputFile, formattedStatements);
}
Expand All @@ -96,7 +101,7 @@ async function writeGeneratedDocuments(language, generatedStatements, outputPath
// External Fragments are rendered only for GraphQL targets
const fragments = generatedStatements['fragments'];
if (fragments.size) {
const formattedStatements = (new GraphQLStatementsFormatter(language)).format(fragments);
const formattedStatements = new GraphQLStatementsFormatter(language).format(fragments);
const outputFile = path.resolve(path.join(outputPath, `fragments.${fileExtension}`));
fs.writeFileSync(outputFile, formattedStatements);
}
Expand All @@ -109,6 +114,6 @@ const FILE_EXTENSION_MAP = {
flow: 'js',
typescript: 'ts',
angular: 'graphql',
}
};

module.exports = generateStatements;
52 changes: 41 additions & 11 deletions packages/amplify-codegen/src/utils/GraphQLStatementsFormatter.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,30 @@ const LINE_DELIMITOR = '\n';
* Utility class to format the generated GraphQL statements based on frontend language type
*/
class GraphQLStatementsFormatter {
constructor(language) {
constructor(language, op) {
this.language = language || 'graphql';
this.opTypeName = {
queries: 'Query',
mutations: 'Mutation',
subscriptions: 'Subscription',
}[op];
this.lintOverrides = [];
this.headerComments = [];
}

get typeDefs() {
if (this.language === 'typescript' && this.opTypeName) {
return [
`import * as APITypes from '../API';`,
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is the location of API.ts stable relative to the generated graphql path? I'm guessing not. How should I calculate the correct relative path here?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No it is not, customers can choose any custom path relative to the project root when they do "amplify add codegen".

`type Generated${this.opTypeName}<InputType, OutputType> = string & {`,
` __generated${this.opTypeName}Input: InputType;`,
` __generated${this.opTypeName}Output: OutputType;`,
`};`,
].join(LINE_DELIMITOR);
}
return '';
}

format(statements) {
switch (this.language) {
case 'javascript':
Expand All @@ -21,10 +39,7 @@ class GraphQLStatementsFormatter {
return this.prettify(this.formatJS(statements));
case 'typescript':
this.headerComments.push(CODEGEN_WARNING);
this.lintOverrides.push(...[
'/* tslint:disable */',
'/* eslint-disable */'
]);
this.lintOverrides.push(...['/* tslint:disable */', '/* eslint-disable */']);
return this.prettify(this.formatJS(statements));
case 'flow':
this.headerComments.push('@flow', CODEGEN_WARNING);
Expand All @@ -36,27 +51,42 @@ class GraphQLStatementsFormatter {
}

formatGraphQL(statements) {
const headerBuffer = this.headerComments.map( comment => `# ${comment}`).join(LINE_DELIMITOR);
const headerBuffer = this.headerComments.map(comment => `# ${comment}`).join(LINE_DELIMITOR);
const statementsBuffer = statements ? [...statements.values()].join(LINE_DELIMITOR) : '';
const formattedOutput = [headerBuffer, LINE_DELIMITOR, statementsBuffer].join(LINE_DELIMITOR);
return formattedOutput;
}

formatJS(statements) {
const lintOverridesBuffer = this.lintOverrides.join(LINE_DELIMITOR);
const headerBuffer = this.headerComments.map( comment => `// ${comment}`).join(LINE_DELIMITOR);
const headerBuffer = this.headerComments.map(comment => `// ${comment}`).join(LINE_DELIMITOR);
const formattedStatements = [];
if (statements) {
for (const [key, value] of statements) {
formattedStatements.push(
`export const ${key} = /* GraphQL */ \`${value}\``
);
const typeTag = this.buildTypeTag(key);
formattedStatements.push(`export const ${key} = /* GraphQL */ \`${value}\`${typeTag}`);
}
}
const formattedOutput = [lintOverridesBuffer, headerBuffer, LINE_DELIMITOR, ...formattedStatements].join(LINE_DELIMITOR);
const formattedOutput = [lintOverridesBuffer, headerBuffer, LINE_DELIMITOR, this.typeDefs, LINE_DELIMITOR, ...formattedStatements].join(
LINE_DELIMITOR,
);
return formattedOutput;
}

buildTypeTag(name) {
if (!this.opTypeName) return '';
if (this.language !== 'typescript') return '';

const titleCasedName = `${name[0].toUpperCase()}${name.slice(1)}`;
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If I'm reading things right, what I'm deriving here is actually name from the ops arrays earlier in the chain. It looks safe to use this name and just fix casing, but in case I'm wrong and to prevent drift, I'm looking at adding an optional flag to generateGraphQLDocuments to return something like Map<string, {query: string, meta: { ...}}> when needed.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added as another PR to compare/contrast: https://github.com/aws-amplify/amplify-codegen/pull/623/files

const variablesTypeName = `APITypes.${titleCasedName}${this.opTypeName}Variables`;
const resultTypeName = `APITypes.${titleCasedName}${this.opTypeName}`;
Comment on lines +81 to +82
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll have a rev by standup tomorrow that uses interfaceNameFromOperation + a new interfaceVariablesNameFromOperation instead of re-deriving those names here. If I'm reading things correctly, that should guard against future mismatches due to drift.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added as another PR to compare/contrast: https://github.com/aws-amplify/amplify-codegen/pull/623/files


return ` as Generated${this.opTypeName}<
${variablesTypeName},
${resultTypeName}
>;`;
}
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was initially thinking this was very gross. But, now I'm wondering if the name I receive here (from the statements map) is actually all the info I need, minus the casing.


prettify(output) {
const parserMap = {
javascript: 'babel',
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
const { GraphQLStatementsFormatter } = require('../../src/utils');
const { GraphQLStatementsFormatter } = require('../../src/utils');

describe('GraphQL statements Formatter', () => {
const statements = new Map();
statements.set('getTodo', `
statements.set(
'getTodo',
`
query GetProject($id: ID!) {
getProject(id: $id) {
id
Expand All @@ -11,31 +13,33 @@ describe('GraphQL statements Formatter', () => {
updatedAt
}
}
`);
`,
);

it('Generates formatted output for JS frontend', () => {
const formattedOutput = (new GraphQLStatementsFormatter('javascript')).format(statements);
const formattedOutput = new GraphQLStatementsFormatter('javascript').format(statements);
expect(formattedOutput).toMatchSnapshot();
});

it('Generates formatted output for TS frontend', () => {
const formattedOutput = (new GraphQLStatementsFormatter('typescript')).format(statements);
console.log({ statements });
const formattedOutput = new GraphQLStatementsFormatter('typescript', 'queries').format(statements);
expect(formattedOutput).toMatchSnapshot();
});

it('Generates formatted output for Flow frontend', () => {
const formattedOutput = (new GraphQLStatementsFormatter('flow')).format(statements);
const formattedOutput = new GraphQLStatementsFormatter('flow').format(statements);
expect(formattedOutput).toMatchSnapshot();
});

it('Generates formatted output for Angular frontend', () => {
const formattedOutput = (new GraphQLStatementsFormatter('angular')).format(statements);
const formattedOutput = new GraphQLStatementsFormatter('angular').format(statements);
// Note that for Angular, we generate in GraphQL language itself.
expect(formattedOutput).toMatchSnapshot();
});

it('Generates formatted output for GraphQL language', () => {
const formattedOutput = (new GraphQLStatementsFormatter('graphql')).format(statements);
const formattedOutput = new GraphQLStatementsFormatter('graphql').format(statements);
expect(formattedOutput).toMatchSnapshot();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -67,15 +67,21 @@ exports[`GraphQL statements Formatter Generates formatted output for TS frontend
/* eslint-disable */
// this is an auto generated file. This will be overwritten

import * as APITypes from \\"../API\\";
type GeneratedQuery<InputType, OutputType> = string & {
__generatedQueryInput: InputType;
__generatedQueryOutput: OutputType;
};

export const getTodo = /* GraphQL */ \`
query GetProject($id: ID!) {
getProject(id: $id) {
id
name
createdAt
updatedAt
query GetProject($id: ID!) {
getProject(id: $id) {
id
name
createdAt
updatedAt
}
}
}
\`;
\` as GeneratedQuery<APITypes.GetTodoQueryVariables, APITypes.GetTodoQuery>;
"
`;
14 changes: 9 additions & 5 deletions packages/graphql-docs-generator/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ export { buildSchema } from './generator/utils/loading';

export function generateGraphQLDocuments(
schema: string,
options: { maxDepth?: number, useExternalFragmentForS3Object?: boolean; typenameIntrospection?: boolean },
options: { maxDepth?: number; useExternalFragmentForS3Object?: boolean; typenameIntrospection?: boolean },
): GeneratedOperations {
const opts = {
maxDepth: 2,
Expand All @@ -19,7 +19,7 @@ export function generateGraphQLDocuments(

const gqlOperations: GQLAllOperations = generateAllOps(extendedSchema, opts.maxDepth, {
useExternalFragmentForS3Object: opts.useExternalFragmentForS3Object,
typenameIntrospection: opts.typenameIntrospection
typenameIntrospection: opts.typenameIntrospection,
});
registerPartials();
registerHelpers();
Expand All @@ -28,7 +28,7 @@ export function generateGraphQLDocuments(
queries: new Map<string, string>(),
mutations: new Map<string, string>(),
subscriptions: new Map<string, string>(),
fragments: new Map<string, string>()
fragments: new Map<string, string>(),
};

['queries', 'mutations', 'subscriptions'].forEach(op => {
Expand All @@ -52,7 +52,7 @@ type GeneratedOperations = {
mutations: Map<string, string>;
subscriptions: Map<string, string>;
fragments: Map<string, string>;
}
};

function renderOperations(operations: Array<GQLTemplateOp>): Map<string, string> {
const renderedOperations = new Map<string, string>();
Expand All @@ -68,6 +68,8 @@ function renderOperations(operations: Array<GQLTemplateOp>): Map<string, string>
}

function renderOperation(operation: GQLTemplateOp): string {
// TODO: cleanup
// console.log('rendering operation', operation);
const templateStr = getOperationPartial();
const template = handlebars.compile(templateStr, {
noEscape: true,
Expand All @@ -77,11 +79,13 @@ function renderOperation(operation: GQLTemplateOp): string {
}

function renderFragments(fragments: Array<GQLTemplateFragment>, useExternalFragmentForS3Object: boolean): Map<string, string> {
// TODO: does it make sense to ferry operation details through in this map
// so that TypeScript downstream can map queries to types more safely?
const renderedFragments = new Map<string, string>();
if (fragments?.length) {
fragments.forEach(fragment => {
const name = fragment.name;
const gql = renderFragment(fragment,useExternalFragmentForS3Object );
const gql = renderFragment(fragment, useExternalFragmentForS3Object);
renderedFragments.set(name, gql);
});
}
Expand Down