diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 00000000..0a0edf0c --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,22 @@ +name: Publish + +on: + release: + types: [published] + +permissions: + id-token: write + contents: read + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version: 24 + - run: npm ci + - run: npm test + - run: npm run lint + - run: npm publish diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index b0ae9b7e..00000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,67 +0,0 @@ -name: Release - -on: - push: - tags: - - 'v*.*.*' - -jobs: - release: - name: Release - runs-on: ubuntu-latest - permissions: - contents: write - steps: - - name: Checkout - uses: actions/checkout@v5 - with: - fetch-depth: 0 - - name: Setup Node.js - uses: actions/setup-node@v5 - with: - node-version: 22 - registry-url: 'https://registry.npmjs.org' - - name: Install dependencies - run: npm ci - - name: Run tests - run: npm run test - - name: Lint - run: npm run lint - - name: Build - run: npm run build - - name: Set version from tag - run: | - VERSION="${GITHUB_REF_NAME#v}" - echo "Setting package.json version to $VERSION" - npm version "$VERSION" --no-git-tag-version --allow-same-version - - name: Publish to npm - run: npm publish --access public - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - - name: Generate changelog - id: changelog - run: | - PREV_TAG=$(git describe --tags --abbrev=0 "${GITHUB_REF_NAME}^" 2>/dev/null || echo "") - if [ -z "$PREV_TAG" ]; then - COMMITS=$(git log --pretty=format:"- %s (%h)" --no-merges) - else - COMMITS=$(git log "$PREV_TAG..${GITHUB_REF_NAME}" --pretty=format:"- %s (%h)" --no-merges) - fi - { - echo "changelog<<__END__" - echo "$COMMITS" - echo "__END__" - } >> "$GITHUB_OUTPUT" - - name: Create GitHub Release - uses: softprops/action-gh-release@v2 - with: - tag_name: ${{ github.ref_name }} - name: ${{ github.ref_name }} - body: | - ## What's Changed - - ${{ steps.changelog.outputs.changelog }} - - **Full Changelog**: https://github.com/sid88in/serverless-appsync-plugin/compare/${{ github.ref_name }} - draft: false - prerelease: false diff --git a/README.md b/README.md index 605479b6..9be25348 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,7 @@ +> **MONEI fork**, published as `@monei-js/serverless-appsync-plugin`. On top of upstream it converts GraphQL descriptions to comments (AppSync rejects descriptions) and uses the osls v4 AWS credentials. +> +> Releasing: bump `version` in `package.json`, merge to `master`, then publish a GitHub release for that version. The `Publish` workflow publishes to npm with trusted publishing. +

npm downloads (weekly) npm downloads (year) diff --git a/package.json b/package.json index d56d97ad..8d8e1bdb 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,10 @@ { - "name": "serverless-appsync-plugin", - "version": "0.0.0-development", + "name": "@monei-js/serverless-appsync-plugin", + "version": "2.14.0", "description": "AWS AppSync support for the Serverless Framework", "main": "lib/index.js", "types": "lib/types/index.d.ts", - "repository": "https://github.com/sid88in/serverless-appsync-plugin", + "repository": "https://github.com/MONEI/serverless-appsync-plugin", "license": "MIT", "files": [ "/lib" @@ -95,5 +95,8 @@ "*.{json,md,yml,yaml}": [ "prettier --write" ] + }, + "publishConfig": { + "access": "public" } } diff --git a/src/__tests__/credentials.test.ts b/src/__tests__/credentials.test.ts new file mode 100644 index 00000000..c294b8e6 --- /dev/null +++ b/src/__tests__/credentials.test.ts @@ -0,0 +1,59 @@ +import * as given from './given'; + +jest.mock('@aws-sdk/credential-providers', () => ({ + fromNodeProviderChain: () => async () => ({ + accessKeyId: 'DEFAULT_CHAIN', + secretAccessKey: 'default-chain', + }), +})); + +type CredentialProvider = () => Promise>; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const resolveCredentials = (plugin: any): Promise> => + (plugin.clientFactory.credentials as CredentialProvider)(); + +describe('AWS credentials', () => { + // osls 4 removed provider.getCredentials(); calling it throws + // AWS_SDK_V2_SURFACE_REMOVED. Falling back to the default chain would ignore + // provider.profile and --aws-profile, so the osls-resolved config must win. + it('uses the credentials osls 4 resolves via getAwsSdkV3Config()', async () => { + const plugin = given.plugin(); + const identity = { accessKeyId: 'OSLS', secretAccessKey: 'osls' }; + Object.assign(plugin['provider'], { + getAwsSdkV3Config: jest.fn().mockResolvedValue({ + region: 'eu-west-1', + credentials: async () => identity, + }), + getCredentials: () => { + throw new Error('AWS_SDK_V2_SURFACE_REMOVED'); + }, + }); + + await expect(resolveCredentials(plugin)).resolves.toEqual(identity); + }); + + it('accepts static credentials from getAwsSdkV3Config()', async () => { + const plugin = given.plugin(); + const identity = { accessKeyId: 'STATIC', secretAccessKey: 'static' }; + Object.assign(plugin['provider'], { + getAwsSdkV3Config: jest.fn().mockResolvedValue({ credentials: identity }), + }); + + await expect(resolveCredentials(plugin)).resolves.toEqual(identity); + }); + + it('uses getCredentials() on Serverless 3 / osls 3', async () => { + const plugin = given.plugin(); + Object.assign(plugin['provider'], { + getCredentials: () => ({ + credentials: { accessKeyId: 'SLS3', secretAccessKey: 'sls3' }, + }), + }); + + await expect(resolveCredentials(plugin)).resolves.toMatchObject({ + accessKeyId: 'SLS3', + secretAccessKey: 'sls3', + }); + }); +}); diff --git a/src/__tests__/fixtures/schemas/multiple/post.graphql b/src/__tests__/fixtures/schemas/multiple/post.graphql index 7af0864d..1fbb96fa 100644 --- a/src/__tests__/fixtures/schemas/multiple/post.graphql +++ b/src/__tests__/fixtures/schemas/multiple/post.graphql @@ -1,8 +1,15 @@ extend type Query { - getPost(id: ID!): Post! + getPost( + "This is an inline description" + id: ID! + ): Post! } extend type Mutation { + """ + This is a description + that includes multiple lines + """ createPost(post: PostInput!): Post! } diff --git a/src/__tests__/schema.test.ts b/src/__tests__/schema.test.ts index 98a6285e..1cb3f0b4 100644 --- a/src/__tests__/schema.test.ts +++ b/src/__tests__/schema.test.ts @@ -32,9 +32,7 @@ describe('schema', () => { createUser(post: UserInput!): User! } - """ - A User - """ + #A User type User { id: ID! name: String! @@ -44,6 +42,7 @@ describe('schema', () => { input UserInput { name: String! } + ", }, "Type": "AWS::AppSync::GraphQLSchema", @@ -61,6 +60,8 @@ describe('schema', () => { ]); expect(schema.generateSchema()).toMatchInlineSnapshot(` "type Mutation { + #This is a description + #that includes multiple lines createPost(post: PostInput!): Post! createUser(post: UserInput!): User! } @@ -72,13 +73,16 @@ describe('schema', () => { updatedAt: AWSDateTime! } - """This is a description""" + #This is a description input PostInput { title: String! } type Query { - getPost(id: ID!): Post! + getPost( + #This is an inline description + id: ID! + ): Post! getUser: User! } @@ -92,7 +96,8 @@ describe('schema', () => { input UserInput { name: String! - }" + } + " `); }); @@ -103,6 +108,8 @@ describe('schema', () => { ]); expect(schema.generateSchema()).toMatchInlineSnapshot(` "type Mutation { + #This is a description + #that includes multiple lines createPost(post: PostInput!): Post! createUser(post: UserInput!): User! } @@ -114,13 +121,16 @@ describe('schema', () => { updatedAt: AWSDateTime! } - """This is a description""" + #This is a description input PostInput { title: String! } type Query { - getPost(id: ID!): Post! + getPost( + #This is an inline description + id: ID! + ): Post! getUser: User! } @@ -134,7 +144,8 @@ describe('schema', () => { input UserInput { name: String! - }" + } + " `); }); @@ -145,6 +156,8 @@ describe('schema', () => { ]); expect(schema.generateSchema()).toMatchInlineSnapshot(` "type Mutation { + #This is a description + #that includes multiple lines createPost(post: PostInput!): Post! createUser(post: UserInput!): User! } @@ -156,13 +169,16 @@ describe('schema', () => { updatedAt: AWSDateTime! } - """This is a description""" + #This is a description input PostInput { title: String! } type Query { - getPost(id: ID!): Post! + getPost( + #This is an inline description + id: ID! + ): Post! getUser: User! } @@ -176,7 +192,8 @@ describe('schema', () => { input UserInput { name: String! - }" + } + " `); }); @@ -253,7 +270,7 @@ describe('schema', () => { expect(schema.generateSchema()).toContain('type Query'); }); - it('should return single files schemas as-is', () => { + it('should return single files schemas with converted descriptions', () => { const api = new Api(given.appSyncConfig(), plugin); const schema = new Schema(api, [ 'src/__tests__/fixtures/schemas/single/schema.graphql', @@ -267,9 +284,7 @@ describe('schema', () => { createUser(post: UserInput!): User! } - """ - A User - """ + #A User type User { id: ID! name: String! @@ -279,6 +294,7 @@ describe('schema', () => { input UserInput { name: String! } + " `); }); diff --git a/src/index.ts b/src/index.ts index e32fbe6d..ed244f89 100644 --- a/src/index.ts +++ b/src/index.ts @@ -70,6 +70,17 @@ const CONSOLE_BASE_URL = 'https://console.aws.amazon.com'; */ const resolveCredentials = (provider: Provider): AwsCredentials => { return async () => { + // osls 4 removed `getCredentials()` (it throws AWS_SDK_V2_SURFACE_REMOVED) + // and exposes its resolved SDK v3 client config instead. + if (typeof provider.getAwsSdkV3Config === 'function') { + const { credentials: v3Credentials } = await provider.getAwsSdkV3Config(); + if (!v3Credentials) { + return fromNodeProviderChain()(); + } + return typeof v3Credentials === 'function' + ? v3Credentials() + : v3Credentials; + } let credentials; try { ({ credentials } = provider.getCredentials()); diff --git a/src/resources/Schema.ts b/src/resources/Schema.ts index 551e9343..b1d9cbd6 100644 --- a/src/resources/Schema.ts +++ b/src/resources/Schema.ts @@ -70,7 +70,7 @@ export class Schema { }; } - valdiateSchema(schema: string) { + validateSchema(schema: string) { const errors = validateSDL(parse(schema)); if (errors.length > 0) { throw new this.api.plugin.serverless.classes.Error( @@ -80,6 +80,47 @@ export class Schema { } } + // AppSync does not support descriptions from June 2018 spec + // https://spec.graphql.org/June2018/#sec-Descriptions + // so they need to be converted to comments, the space after the # will also be included + // by AppSync in the generated description so we remove it + convertDescriptions(schema: string): string { + const lines = schema.split('\n'); + const singleLineComment = /^(? *)"(?[^"]+?)"$/; + const singleLineMultilineComment = /^(? *)"""(?.+?)"""$/; + const multilineCommentDelimiter = /^(? *)"""$/; + + let inComment = false; + let result = ''; + + for (const line of lines) { + switch (true) { + case singleLineComment.test(line): + result += `${line.match(singleLineComment)?.groups?.indent}#${ + line.match(singleLineComment)?.groups?.comment + }\n`; + break; + case singleLineMultilineComment.test(line): + result += `${ + line.match(singleLineMultilineComment)?.groups?.indent + }#${line.match(singleLineMultilineComment)?.groups?.comment}\n`; + break; + case multilineCommentDelimiter.test(line): + inComment = !inComment; + break; + case inComment: + result += `${ + line.match(/^(? *)/)?.groups?.indent + }#${line.trimStart()}\n`; + break; + default: + result += line + '\n'; + } + } + + return result; + } + generateSchema() { const schemaFiles = flatten( globby.sync( @@ -98,23 +139,25 @@ export class Schema { return this.stripReservedDefinitions(fs.readFileSync(file, 'utf8')); }); - this.valdiateSchema(AWS_TYPES + '\n' + schemas.join('\n')); + this.validateSchema(AWS_TYPES + '\n' + schemas.join('\n')); // Return single (already stripped) files as-is. if (schemas.length === 1) { - return schemas[0]; + return this.convertDescriptions(schemas[0]); } // AppSync does not support Object extensions // https://spec.graphql.org/October2021/#sec-Object-Extensions // Merge the schemas - return print( - mergeTypeDefs(schemas, { - forceSchemaDefinition: false, - useSchemaDefinition: false, - sort: true, - throwOnConflict: true, - }), + return this.convertDescriptions( + print( + mergeTypeDefs(schemas, { + forceSchemaDefinition: false, + useSchemaDefinition: false, + sort: true, + throwOnConflict: true, + }), + ), ); } diff --git a/src/types/serverless.d.ts b/src/types/serverless.d.ts index f6ffd362..a9e271ad 100644 --- a/src/types/serverless.d.ts +++ b/src/types/serverless.d.ts @@ -125,6 +125,14 @@ declare module 'serverless/lib/plugins/aws/provider.js' { region?: string; signatureVersion?: string; }; + // osls 4 only: client config (region, credentials, retries, proxy) for + // plugin-built SDK v3 clients. Replaces request() and getCredentials(). + getAwsSdkV3Config?: () => Promise<{ + region?: string; + credentials?: + | import('@aws-sdk/types').AwsCredentialIdentity + | import('@aws-sdk/types').AwsCredentialIdentityProvider; + }>; } export default Provider;