├── .gitignore ├── LICENSE ├── README.md ├── index.js ├── lib ├── ServerlessAWSPlugin.js ├── checkBucketExists.js ├── getBucketName.js ├── getDistFolder.js ├── purgeBucket.js ├── uploadFileToBucket.js └── uploadFolderToBucket.js ├── package-lock.json ├── package.json └── resources.yml /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | 11 | # Directory for instrumented libs generated by jscoverage/JSCover 12 | lib-cov 13 | 14 | # Coverage directory used by tools like istanbul 15 | coverage 16 | 17 | # nyc test coverage 18 | .nyc_output 19 | 20 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 21 | .grunt 22 | 23 | # node-waf configuration 24 | .lock-wscript 25 | 26 | # Compiled binary addons (http://nodejs.org/api/addons.html) 27 | build/Release 28 | 29 | # Dependency directories 30 | node_modules 31 | jspm_packages 32 | 33 | # Optional npm cache directory 34 | .npm 35 | 36 | # Optional REPL history 37 | .node_repl_history 38 | /.idea 39 | *.iml 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Scott Donnelly 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # serverless-apig-s3 2 | 3 | This Serverless plugin automates the process of both configuring AWS to serve static front-end 4 | content and deploying your client-side bundle. 5 | 6 | It creates an S3 bucket to hold your front-end content, and adds two routes to API Gateway: 7 | 8 | * `GET / => bucket/index.html` 9 | * `GET /assets/* => bucket/*` 10 | 11 | This allows your API and front-end assets to be served from the same domain, sidestepping 12 | any CORS issues. CloudFront is also not used. The combination of these two properties 13 | makes this plugin a good fit for a dev stage environment. 14 | 15 | ### Installation 16 | 17 | ```bash 18 | npm i -D serverless-apig-s3 19 | ``` 20 | 21 | This plugin requires node > 7.6 because fuck callbacks. 22 | 23 | ### Configuration 24 | 25 | serverless.yml: 26 | 27 | ```yaml 28 | plugins: 29 | - serverless-apig-s3 30 | 31 | custom: 32 | apigs3: 33 | dist: client/dist # path within service to find content to upload (default: client/dist) 34 | dotFiles: true # include files beginning with a dot in resources and uploads (default: false) 35 | topFiles: true # create routes for top-level files in dist folder (default: false) 36 | resourceName: static # route path for static assets (default: assets) 37 | resourcePath: /dist # path prefix for assets in s3 bucket (default: '') 38 | ``` 39 | 40 | ### Usage 41 | 42 | ```bash 43 | sls deploy # ensure that sls deploy has been run so that this plugin's resources exist. 44 | sls client deploy # uploads client build artifacts to s3 45 | ``` 46 | 47 | Something missing? More documentation? All Issues / PRs welcome at https://github.com/sdd/serverless-apig-s3 48 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const { merge } = require('lodash'); 3 | const { get } = require('lodash'); 4 | const { map } = require('lodash'); 5 | const { cloneDeep } = require('lodash'); 6 | const pify = require('pify'); 7 | const fs = pify(require('fs')); 8 | const path = require('path'); 9 | const ServerlessAWSPlugin = require('./lib/ServerlessAWSPlugin'); 10 | const checkBucketExists = require('./lib/checkBucketExists'); 11 | const getBucketName = require('./lib/getBucketName'); 12 | const getDistFolder = require('./lib/getDistFolder'); 13 | const purgeBucket = require('./lib/purgeBucket'); 14 | const uploadFileToBucket = require('./lib/uploadFileToBucket'); 15 | const uploadFolderToBucket = require('./lib/uploadFolderToBucket'); 16 | 17 | module.exports = class ServerlessApigS3 extends ServerlessAWSPlugin { 18 | 19 | constructor(serverless, options) { 20 | super(serverless, options); 21 | 22 | this.stackName = serverless.service.service; 23 | 24 | this.commands = { 25 | client: { 26 | usage: 'Deploy client code', 27 | lifecycleEvents:[ 'client', 'deploy' ], 28 | commands: { 29 | deploy: { 30 | usage: 'Deploy serverless client code', 31 | lifecycleEvents:[ 'deploy' ] 32 | } 33 | } 34 | } 35 | }; 36 | 37 | this.hooks = { 38 | 'before:package:createDeploymentArtifacts': () => this.mergeApigS3Resources(), 39 | 'client:client': () => { this.serverless.cli.log(this.commands.client.usage); }, 40 | 'client:deploy:deploy': () => this.deploy() 41 | }; 42 | 43 | Object.assign(this, 44 | checkBucketExists, 45 | getBucketName, 46 | getDistFolder, 47 | purgeBucket, 48 | uploadFileToBucket, 49 | uploadFolderToBucket 50 | ); 51 | } 52 | 53 | updateIamRoleAndPolicyNames(roleResource) { 54 | roleResource[ "Properties" ][ "RoleName" ] = this.stackName + "_" + roleResource[ "Properties" ][ "RoleName" ] + "_" + this.options.stage; 55 | roleResource[ "Properties" ][ "Policies" ][ 0 ][ "PolicyName" ] = this.stackName + "_" + roleResource[ "Properties" ][ "Policies" ][ 0 ][ "PolicyName" ] + "_" + thiis.options.stage; 56 | return roleResource 57 | } 58 | 59 | async mergeApigS3Resources() { 60 | const oldCwd = process.cwd(); 61 | const ownResources = await this.serverless.yamlParser.parse( 62 | path.resolve(__dirname, 'resources.yml') 63 | ); 64 | 65 | process.chdir(oldCwd); 66 | 67 | const withIndex = get(this.serverless, 'service.custom.apigs3.withIndex', true); 68 | if(!withIndex) { 69 | delete ownResources['Resources']['ApiGatewayMethodIndexGet']; 70 | delete ownResources['Resources']['ApiGatewayMethodDefaultRouteGet']; 71 | delete ownResources['Resources']['ApiGatewayResourceDefaultRoute']; 72 | } 73 | 74 | const topFiles = get(this.serverless, 'service.custom.apigs3.topFiles', false); 75 | if(topFiles) { 76 | this.getDistFolder(); 77 | 78 | const dirContents = map(await fs.readdir(this.clientPath), 79 | name => path.join(this.clientPath, name) 80 | ); 81 | 82 | const dotFiles = get(this.serverless, 'service.custom.apigs3.dotFiles', false); 83 | 84 | await Promise.all(dirContents.map(async item => { 85 | const stat = await fs.stat(item); 86 | if (!stat.isFile()) return; 87 | 88 | const pathPart = path.basename(item); 89 | if (pathPart === 'index.html' || (pathPart[0] === '.' && !dotFiles)) return; 90 | 91 | const routeName = this.aws.naming.getResourceLogicalId(pathPart); 92 | const routeId = this.aws.naming.extractResourceId(routeName); 93 | const route = cloneDeep(ownResources['Resources']['ApiGatewayResourceAssets']); 94 | 95 | route['Properties']['PathPart'] = pathPart; 96 | ownResources['Resources'][routeName] = route; 97 | 98 | const methodName = this.aws.naming.getMethodLogicalId(routeId, 'Get'); 99 | const method = cloneDeep(ownResources['Resources']['ApiGatewayMethodDefaultRouteGet']); 100 | 101 | method['Properties']['Integration']['Uri']['Fn::Join'][1].splice(4, 1, '/' + pathPart); 102 | method['Properties']['ResourceId']['Ref'] = routeName; 103 | ownResources['Resources'][methodName] = method; 104 | })); 105 | } 106 | 107 | const resourceName = get(this.serverless, 'service.custom.apigs3.resourceName', 'assets'); 108 | ownResources['Resources']['ApiGatewayResourceAssets']['Properties']['PathPart'] = resourceName; 109 | 110 | const resourcePath = get(this.serverless, 'service.custom.apigs3.resourcePath', ''); 111 | if (resourcePath) { 112 | const method = ownResources['Resources']['ApiGatewayMethodAssetsItemGet']; 113 | method['Properties']['Integration']['Uri']['Fn::Join'][1].splice(4, 0, resourcePath); 114 | } 115 | 116 | const existing = this.serverless.service.provider.compiledCloudFormationTemplate; 117 | 118 | ownResources[ "Resources" ][ "IamRoleApiGatewayS3" ] = this.updateIamRoleAndPolicyNames(ownResources[ "Resources" ][ "IamRoleApiGatewayS3" ], this.stage); 119 | 120 | merge(existing, ownResources); 121 | } 122 | 123 | async deploy() { 124 | this.getDistFolder(); 125 | await this.getBucketName(); 126 | 127 | await this.checkBucketExists(); 128 | this.log('Deploying client to stage "' + this.stage + '" in region "' + this.region + '"...'); 129 | 130 | this.log('emptying current bucket contents...'); 131 | await this.purgeBucket(this.bucketName); 132 | 133 | this.log('uploading content...'); 134 | await this.uploadFolderToBucket(this.clientPath, this.bucketName); 135 | this.log('Deployment complete.'); 136 | } 137 | }; 138 | -------------------------------------------------------------------------------- /lib/ServerlessAWSPlugin.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const { get } = require('lodash'); 3 | 4 | module.exports = class ServerlessAWSPlugin { 5 | 6 | constructor(serverless, options) { 7 | 8 | Object.assign(this, { serverless, options }); 9 | 10 | this.stage = options.stage || get(serverless, 'service.provider.stage'); 11 | this.region = options.region || get(serverless, 'service.provider.region'); 12 | 13 | this.provider = 'aws'; 14 | this.aws = this.serverless.getProvider(this.provider); 15 | 16 | this.log = serverless.cli.log.bind(serverless.cli); 17 | } 18 | 19 | s3Request(fn, params = {}) { 20 | return this.aws.request('S3', fn, params, this.stage, this.region); 21 | } 22 | }; 23 | -------------------------------------------------------------------------------- /lib/checkBucketExists.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const { find } = require('lodash'); 3 | 4 | module.exports = { 5 | async checkBucketExists () { 6 | const { Buckets } = await this.s3Request('listBuckets'); 7 | 8 | const bucket = find(Buckets, { Name: this.bucketName }).Name; 9 | if (!bucket) { 10 | throw new Error(`Bucket "${ this.bucketName }" not found! Re-run sls deploy`); 11 | } 12 | } 13 | }; 14 | -------------------------------------------------------------------------------- /lib/getBucketName.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const { find } = require('lodash'); 3 | 4 | module.exports = { 5 | async getBucketName() { 6 | const StackName = this.aws.naming.getStackName(this.options.stage); 7 | 8 | const { Stacks } = await this.aws.request( 9 | 'CloudFormation', 10 | 'describeStacks', 11 | { StackName }, 12 | this.options.stage, 13 | this.options.region 14 | ); 15 | const { Outputs } = find(Stacks, { StackName }); 16 | const { OutputValue } = find(Outputs, { OutputKey: 'S3BucketFrontEndName' }); 17 | 18 | this.log('Target Bucket: ' + JSON.stringify(OutputValue, null, 2)); 19 | this.bucketName = OutputValue; 20 | } 21 | }; 22 | -------------------------------------------------------------------------------- /lib/getDistFolder.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const { get } = require('lodash'); 3 | const path = require('path'); 4 | 5 | module.exports = { 6 | getDistFolder() { 7 | const Utils = this.serverless.utils; 8 | const Error = this.serverless.classes.Error; 9 | 10 | const dist = get(this.serverless, 'service.custom.apigs3.dist', 'client/dist'); 11 | 12 | if (!Utils.dirExistsSync(path.join(this.serverless.config.servicePath, dist))) { 13 | throw new Error(`Could not find "${ dist }" folder in your service root.`); 14 | } 15 | this.clientPath = path.join(this.serverless.config.servicePath, dist); 16 | } 17 | }; 18 | -------------------------------------------------------------------------------- /lib/purgeBucket.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | async purgeBucket(Bucket) { 5 | const params = { Bucket }; 6 | const { Contents } = await this.s3Request('listObjectsV2', params); 7 | 8 | if (!Contents.length) { return; } 9 | const Objects = Contents.map(({ Key }) => ({ Key })); 10 | 11 | params.Delete = { Objects }; 12 | await this.s3Request('deleteObjects', params); 13 | } 14 | }; 15 | -------------------------------------------------------------------------------- /lib/uploadFileToBucket.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const pify = require('pify'); 3 | const path = require('path'); 4 | const fs = pify(require('fs')); 5 | const mimeLookup = require('mime-types').lookup; 6 | 7 | const ACL = "public-read"; 8 | 9 | module.exports = { 10 | async uploadFileToBucket(filePath, Bucket) { 11 | 12 | const Body = await fs.readFile(filePath); 13 | const ContentType = mimeLookup(filePath).toString(); 14 | 15 | const Key = filePath.replace(this.clientPath, '').substr(1).replace('\\', '/'); 16 | 17 | this.log(`uploading ${ Key } with MIME type ${ ContentType }`); 18 | await this.s3Request('putObject', { ACL, Bucket, Body, ContentType, Key }); 19 | } 20 | }; 21 | -------------------------------------------------------------------------------- /lib/uploadFolderToBucket.js: -------------------------------------------------------------------------------- 1 | const { get, map } = require('lodash'); 2 | const path = require('path'); 3 | const pify = require('pify'); 4 | const fs = pify(require('fs')); 5 | 6 | module.exports = { 7 | async uploadFolderToBucket(folder, bucket) { 8 | const dirContents = map(await fs.readdir(folder), 9 | name => path.join(folder, name) 10 | ); 11 | 12 | const dotFiles = get(this.serverless, 'service.custom.apigs3.dotFiles', false); 13 | 14 | await Promise.all(dirContents.map(async item => { 15 | if (path.basename(item)[0] === '.' && !dotFiles) return; 16 | 17 | const stat = await fs.stat(item); 18 | 19 | if (stat.isFile()) { 20 | return this.uploadFileToBucket(item, bucket); 21 | 22 | } else if (stat.isDirectory()) { 23 | return this.uploadFolderToBucket(item, bucket); 24 | } 25 | })); 26 | } 27 | }; 28 | -------------------------------------------------------------------------------- /package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "serverless-apig-s3", 3 | "version": "1.3.0", 4 | "lockfileVersion": 1, 5 | "requires": true, 6 | "dependencies": { 7 | "lodash": { 8 | "version": "https://registry.npmjs.org/lodash/-/lodash-4.17.4.tgz", 9 | "integrity": "sha1-eCA6TRwyiuHYbcpkYONptX9AVa4=" 10 | }, 11 | "mime-db": { 12 | "version": "https://registry.npmjs.org/mime-db/-/mime-db-1.27.0.tgz", 13 | "integrity": "sha1-gg9XIpa70g7CXtVeW13oaeVDbrE=" 14 | }, 15 | "mime-types": { 16 | "version": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.15.tgz", 17 | "integrity": "sha1-pOv1BkCUVpI3uM9wBGd20J/JKu0=", 18 | "requires": { 19 | "mime-db": "https://registry.npmjs.org/mime-db/-/mime-db-1.27.0.tgz" 20 | } 21 | }, 22 | "pify": { 23 | "version": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", 24 | "integrity": "sha1-7RQaasBDqEnqWISY59yosVMw6Qw=" 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "serverless-apig-s3", 3 | "version": "1.3.0", 4 | "description": "automates the process of both configuring AWS to serve static front-end content and deploying your client-side bundle.", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/sdd/serverless-apig-s3.git" 12 | }, 13 | "keywords": [ 14 | "serverless", 15 | "serverless-plugin" 16 | ], 17 | "author": "Scott Donnelly", 18 | "license": "MIT", 19 | "bugs": { 20 | "url": "https://github.com/sdd/serverless-apig-s3/issues" 21 | }, 22 | "homepage": "https://github.com/sdd/serverless-apig-s3#readme", 23 | "dependencies": { 24 | "lodash": "^4.17.4", 25 | "mime-types": "^2.1.15", 26 | "pify": "^2.3.0" 27 | }, 28 | "devDependencies": {}, 29 | "engines": { 30 | "node": ">=7.6" 31 | }, 32 | "contributors": [ 33 | "Scott Donnelly (https://github.com/sdd)", 34 | "Greg Thornton (https://github.com/xdissent)" 35 | ] 36 | } 37 | -------------------------------------------------------------------------------- /resources.yml: -------------------------------------------------------------------------------- 1 | Resources: 2 | S3BucketFrontEnd: 3 | Type: AWS::S3::Bucket 4 | Properties: 5 | AccessControl: PublicRead 6 | IamRoleApiGatewayS3: 7 | Type: AWS::IAM::Role 8 | Properties: 9 | RoleName: role-apig-s3-read 10 | Path: / 11 | AssumeRolePolicyDocument: 12 | Version: '2012-10-17' 13 | Statement: 14 | - Effect: Allow 15 | Principal: 16 | Service: 17 | - apigateway.amazonaws.com 18 | Action: 19 | - "sts:AssumeRole" 20 | Policies: 21 | - PolicyName: policy-apig-s3-read 22 | PolicyDocument: 23 | Version: '2012-10-17' 24 | Statement: 25 | - Effect: Allow 26 | Action: 27 | - "s3:get*" 28 | - "s3:list*" 29 | Resource: "*" 30 | ApiGatewayResourceDefaultRoute: 31 | Type: AWS::ApiGateway::Resource 32 | Properties: 33 | ParentId: 34 | Fn::GetAtt: 35 | - ApiGatewayRestApi 36 | - RootResourceId 37 | PathPart: "{path+}" 38 | RestApiId: 39 | Ref: ApiGatewayRestApi 40 | ApiGatewayResourceAssets: 41 | Type: AWS::ApiGateway::Resource 42 | Properties: 43 | ParentId: 44 | Fn::GetAtt: 45 | - ApiGatewayRestApi 46 | - RootResourceId 47 | PathPart: assets 48 | RestApiId: 49 | Ref: ApiGatewayRestApi 50 | ApiGatewayResourceAssetsItem: 51 | Type: AWS::ApiGateway::Resource 52 | Properties: 53 | ParentId: 54 | Ref: 55 | ApiGatewayResourceAssets 56 | PathPart: "{item+}" 57 | RestApiId: 58 | Ref: ApiGatewayRestApi 59 | ApiGatewayMethodAssetsItemGet: 60 | Type: AWS::ApiGateway::Method 61 | Properties: 62 | AuthorizationType: NONE 63 | HttpMethod: GET 64 | RequestParameters: 65 | method.request.path.item: true 66 | MethodResponses: 67 | - StatusCode: 200 68 | ResponseParameters: 69 | method.response.header.Content-Type: true 70 | method.response.header.Content-Length: true 71 | Integration: 72 | Type: AWS 73 | IntegrationHttpMethod: GET 74 | Credentials: 75 | Fn::GetAtt: 76 | - IamRoleApiGatewayS3 77 | - Arn 78 | Uri: 79 | Fn::Join: 80 | - "" 81 | - - "arn:aws:apigateway:" 82 | - Ref: AWS::Region 83 | - ":s3:path/" 84 | - Ref: S3BucketFrontEnd 85 | - "/{object}" 86 | RequestParameters: 87 | integration.request.path.object: method.request.path.item 88 | IntegrationResponses: 89 | - StatusCode: 200 90 | ResponseParameters: 91 | method.response.header.Content-Type: "integration.response.header.Content-Type" 92 | method.response.header.Content-Length: "integration.response.header.Content-Length" 93 | ResourceId: 94 | Ref: ApiGatewayResourceAssetsItem 95 | RestApiId: 96 | Ref: ApiGatewayRestApi 97 | ApiGatewayMethodIndexGet: 98 | Type: AWS::ApiGateway::Method 99 | Properties: 100 | AuthorizationType: NONE 101 | HttpMethod: GET 102 | MethodResponses: 103 | - StatusCode: 200 104 | ResponseParameters: 105 | method.response.header.Content-Type: true 106 | method.response.header.Content-Length: true 107 | Integration: 108 | Type: AWS 109 | IntegrationHttpMethod: GET 110 | Credentials: 111 | Fn::GetAtt: 112 | - IamRoleApiGatewayS3 113 | - Arn 114 | Uri: 115 | Fn::Join: 116 | - "" 117 | - - "arn:aws:apigateway:" 118 | - Ref: AWS::Region 119 | - ":s3:path/" 120 | - Ref: S3BucketFrontEnd 121 | - "/index.html" 122 | IntegrationResponses: 123 | - StatusCode: 200 124 | ResponseParameters: 125 | method.response.header.Content-Type: "integration.response.header.Content-Type" 126 | method.response.header.Content-Length: "integration.response.header.Content-Length" 127 | ResourceId: 128 | Fn::GetAtt: 129 | - ApiGatewayRestApi 130 | - RootResourceId 131 | RestApiId: 132 | Ref: ApiGatewayRestApi 133 | ApiGatewayMethodDefaultRouteGet: 134 | Type: AWS::ApiGateway::Method 135 | Properties: 136 | AuthorizationType: NONE 137 | HttpMethod: GET 138 | MethodResponses: 139 | - StatusCode: 200 140 | ResponseParameters: 141 | method.response.header.Content-Type: true 142 | method.response.header.Content-Length: true 143 | Integration: 144 | Type: AWS 145 | IntegrationHttpMethod: GET 146 | Credentials: 147 | Fn::GetAtt: 148 | - IamRoleApiGatewayS3 149 | - Arn 150 | Uri: 151 | Fn::Join: 152 | - "" 153 | - - "arn:aws:apigateway:" 154 | - Ref: AWS::Region 155 | - ":s3:path/" 156 | - Ref: S3BucketFrontEnd 157 | - "/index.html" 158 | IntegrationResponses: 159 | - StatusCode: 200 160 | ResponseParameters: 161 | method.response.header.Content-Type: "integration.response.header.Content-Type" 162 | method.response.header.Content-Length: "integration.response.header.Content-Length" 163 | ResourceId: 164 | Ref: ApiGatewayResourceDefaultRoute 165 | RestApiId: 166 | Ref: ApiGatewayRestApi 167 | Outputs: 168 | S3BucketFrontEndName: 169 | Value: 170 | Ref: S3BucketFrontEnd 171 | --------------------------------------------------------------------------------