├── .github └── PULL_REQUEST_TEMPLATE.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── CodeAfterExercise1 ├── .gitignore ├── Gemfile ├── app │ ├── Gemfile │ ├── posts.rb │ └── web_api.rb ├── packaged.yaml ├── template.yaml └── tests │ └── app │ └── test_web_api.rb ├── CodeAfterExercise2 ├── .gitignore ├── Gemfile ├── app │ ├── Gemfile │ ├── event_handlers.rb │ ├── posts.rb │ └── web_api.rb ├── packaged.yaml ├── template.yaml └── tests │ └── app │ ├── test_event_handlers.rb │ └── test_web_api.rb ├── CodeAfterExercise3 ├── .gitignore ├── Gemfile ├── app │ ├── Gemfile │ ├── event_handlers.rb │ ├── posts.rb │ └── web_api.rb ├── packaged.yaml ├── template.yaml └── tests │ └── app │ ├── test_event_handlers.rb │ └── test_web_api.rb ├── CodeAfterExercise4 ├── .gitignore ├── Gemfile ├── app │ ├── Gemfile │ ├── event_handlers.rb │ ├── posts.rb │ └── web_api.rb ├── buildspec.yml ├── pipeline │ └── pipeline-template.yml ├── template.yaml └── tests │ └── app │ ├── test_event_handlers.rb │ └── test_web_api.rb ├── LICENSE ├── README.md ├── StartingPointCode ├── .gitignore ├── Gemfile ├── app │ └── Gemfile └── template.yaml └── WORKSHEET.md /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | *Issue #, if available:* 2 | 3 | *Description of changes:* 4 | 5 | 6 | By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice. 7 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | ## Code of Conduct 2 | This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct). 3 | For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact 4 | opensource-codeofconduct@amazon.com with any additional questions or comments. 5 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing Guidelines 2 | 3 | Thank you for your interest in contributing to our project. Whether it's a bug report, new feature, correction, or additional 4 | documentation, we greatly value feedback and contributions from our community. 5 | 6 | Please read through this document before submitting any issues or pull requests to ensure we have all the necessary 7 | information to effectively respond to your bug report or contribution. 8 | 9 | 10 | ## Reporting Bugs/Feature Requests 11 | 12 | We welcome you to use the GitHub issue tracker to report bugs or suggest features. 13 | 14 | When filing an issue, please check [existing open](https://github.com/aws-samples/aws-ruby-lambda-rate-dogs/issues), or [recently closed](https://github.com/aws-samples/aws-ruby-lambda-rate-dogs/issues?utf8=%E2%9C%93&q=is%3Aissue%20is%3Aclosed%20), issues to make sure somebody else hasn't already 15 | reported the issue. Please try to include as much information as you can. Details like these are incredibly useful: 16 | 17 | * A reproducible test case or series of steps 18 | * The version of our code being used 19 | * Any modifications you've made relevant to the bug 20 | * Anything unusual about your environment or deployment 21 | 22 | 23 | ## Contributing via Pull Requests 24 | Contributions via pull requests are much appreciated. Before sending us a pull request, please ensure that: 25 | 26 | 1. You are working against the latest source on the *master* branch. 27 | 2. You check existing open, and recently merged, pull requests to make sure someone else hasn't addressed the problem already. 28 | 3. You open an issue to discuss any significant work - we would hate for your time to be wasted. 29 | 30 | To send us a pull request, please: 31 | 32 | 1. Fork the repository. 33 | 2. Modify the source; please focus on the specific change you are contributing. If you also reformat all the code, it will be hard for us to focus on your change. 34 | 3. Ensure local tests pass. 35 | 4. Commit to your fork using clear commit messages. 36 | 5. Send us a pull request, answering any default questions in the pull request interface. 37 | 6. Pay attention to any automated CI failures reported in the pull request, and stay involved in the conversation. 38 | 39 | GitHub provides additional document on [forking a repository](https://help.github.com/articles/fork-a-repo/) and 40 | [creating a pull request](https://help.github.com/articles/creating-a-pull-request/). 41 | 42 | 43 | ## Finding contributions to work on 44 | Looking at the existing issues is a great way to find something to contribute on. As our projects, by default, use the default GitHub issue labels (enhancement/bug/duplicate/help wanted/invalid/question/wontfix), looking at any ['help wanted'](https://github.com/aws-samples/aws-ruby-lambda-rate-dogs/labels/help%20wanted) issues is a great place to start. 45 | 46 | 47 | ## Code of Conduct 48 | This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct). 49 | For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact 50 | opensource-codeofconduct@amazon.com with any additional questions or comments. 51 | 52 | 53 | ## Security issue notifications 54 | If you discover a potential security issue in this project we ask that you notify AWS/Amazon Security via our [vulnerability reporting page](http://aws.amazon.com/security/vulnerability-reporting/). Please do **not** create a public github issue. 55 | 56 | 57 | ## Licensing 58 | 59 | See the [LICENSE](https://github.com/aws-samples/aws-ruby-lambda-rate-dogs/blob/master/LICENSE) file for our project's licensing. We will ask you to confirm the licensing of your contribution. 60 | 61 | We may ask you to sign a [Contributor License Agreement (CLA)](http://en.wikipedia.org/wiki/Contributor_License_Agreement) for larger changes. 62 | -------------------------------------------------------------------------------- /CodeAfterExercise1/.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.gitignore.io/api/osx,linux,python,windows,pycharm,visualstudiocode 3 | 4 | ### Linux ### 5 | *~ 6 | 7 | # temporary files which can be created if a process still has a handle open of a deleted file 8 | .fuse_hidden* 9 | 10 | # KDE directory preferences 11 | .directory 12 | 13 | # Linux trash folder which might appear on any partition or disk 14 | .Trash-* 15 | 16 | # .nfs files are created when an open file is removed but is still being accessed 17 | .nfs* 18 | 19 | ### OSX ### 20 | *.DS_Store 21 | .AppleDouble 22 | .LSOverride 23 | 24 | # Icon must end with two \r 25 | Icon 26 | 27 | # Thumbnails 28 | ._* 29 | 30 | # Files that might appear in the root of a volume 31 | .DocumentRevisions-V100 32 | .fseventsd 33 | .Spotlight-V100 34 | .TemporaryItems 35 | .Trashes 36 | .VolumeIcon.icns 37 | .com.apple.timemachine.donotpresent 38 | 39 | # Directories potentially created on remote AFP share 40 | .AppleDB 41 | .AppleDesktop 42 | Network Trash Folder 43 | Temporary Items 44 | .apdisk 45 | 46 | ### PyCharm ### 47 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm 48 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 49 | 50 | # User-specific stuff: 51 | .idea/**/workspace.xml 52 | .idea/**/tasks.xml 53 | .idea/dictionaries 54 | 55 | # Sensitive or high-churn files: 56 | .idea/**/dataSources/ 57 | .idea/**/dataSources.ids 58 | .idea/**/dataSources.xml 59 | .idea/**/dataSources.local.xml 60 | .idea/**/sqlDataSources.xml 61 | .idea/**/dynamic.xml 62 | .idea/**/uiDesigner.xml 63 | 64 | # Gradle: 65 | .idea/**/gradle.xml 66 | .idea/**/libraries 67 | 68 | # CMake 69 | cmake-build-debug/ 70 | 71 | # Mongo Explorer plugin: 72 | .idea/**/mongoSettings.xml 73 | 74 | ## File-based project format: 75 | *.iws 76 | 77 | ## Plugin-specific files: 78 | 79 | # IntelliJ 80 | /out/ 81 | 82 | # mpeltonen/sbt-idea plugin 83 | .idea_modules/ 84 | 85 | # JIRA plugin 86 | atlassian-ide-plugin.xml 87 | 88 | # Cursive Clojure plugin 89 | .idea/replstate.xml 90 | 91 | # Ruby plugin and RubyMine 92 | /.rakeTasks 93 | 94 | # Crashlytics plugin (for Android Studio and IntelliJ) 95 | com_crashlytics_export_strings.xml 96 | crashlytics.properties 97 | crashlytics-build.properties 98 | fabric.properties 99 | 100 | ### PyCharm Patch ### 101 | # Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 102 | 103 | # *.iml 104 | # modules.xml 105 | # .idea/misc.xml 106 | # *.ipr 107 | 108 | # Sonarlint plugin 109 | .idea/sonarlint 110 | 111 | ### Python ### 112 | # Byte-compiled / optimized / DLL files 113 | __pycache__/ 114 | *.py[cod] 115 | *$py.class 116 | 117 | # C extensions 118 | *.so 119 | 120 | # Distribution / packaging 121 | .Python 122 | build/ 123 | develop-eggs/ 124 | dist/ 125 | downloads/ 126 | eggs/ 127 | .eggs/ 128 | lib/ 129 | lib64/ 130 | parts/ 131 | sdist/ 132 | var/ 133 | wheels/ 134 | *.egg-info/ 135 | .installed.cfg 136 | *.egg 137 | 138 | # PyInstaller 139 | # Usually these files are written by a python script from a template 140 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 141 | *.manifest 142 | *.spec 143 | 144 | # Installer logs 145 | pip-log.txt 146 | pip-delete-this-directory.txt 147 | 148 | # Unit test / coverage reports 149 | htmlcov/ 150 | .tox/ 151 | .coverage 152 | .coverage.* 153 | .cache 154 | .pytest_cache/ 155 | nosetests.xml 156 | coverage.xml 157 | *.cover 158 | .hypothesis/ 159 | 160 | # Translations 161 | *.mo 162 | *.pot 163 | 164 | # Flask stuff: 165 | instance/ 166 | .webassets-cache 167 | 168 | # Scrapy stuff: 169 | .scrapy 170 | 171 | # Sphinx documentation 172 | docs/_build/ 173 | 174 | # PyBuilder 175 | target/ 176 | 177 | # Jupyter Notebook 178 | .ipynb_checkpoints 179 | 180 | # pyenv 181 | .python-version 182 | 183 | # celery beat schedule file 184 | celerybeat-schedule.* 185 | 186 | # SageMath parsed files 187 | *.sage.py 188 | 189 | # Environments 190 | .env 191 | .venv 192 | env/ 193 | venv/ 194 | ENV/ 195 | env.bak/ 196 | venv.bak/ 197 | 198 | # Spyder project settings 199 | .spyderproject 200 | .spyproject 201 | 202 | # Rope project settings 203 | .ropeproject 204 | 205 | # mkdocs documentation 206 | /site 207 | 208 | # mypy 209 | .mypy_cache/ 210 | 211 | ### VisualStudioCode ### 212 | .vscode/* 213 | !.vscode/settings.json 214 | !.vscode/tasks.json 215 | !.vscode/launch.json 216 | !.vscode/extensions.json 217 | .history 218 | 219 | ### Windows ### 220 | # Windows thumbnail cache files 221 | Thumbs.db 222 | ehthumbs.db 223 | ehthumbs_vista.db 224 | 225 | # Folder config file 226 | Desktop.ini 227 | 228 | # Recycle Bin used on file shares 229 | $RECYCLE.BIN/ 230 | 231 | # Windows Installer files 232 | *.cab 233 | *.msi 234 | *.msm 235 | *.msp 236 | 237 | # Windows shortcuts 238 | *.lnk 239 | 240 | # Build folder 241 | 242 | */build/* 243 | 244 | # End of https://www.gitignore.io/api/osx,linux,python,windows,pycharm,visualstudiocode 245 | 246 | # Don't put ZIP files into source control 247 | *.zip -------------------------------------------------------------------------------- /CodeAfterExercise1/Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | # Load app Gemfile dependencies 4 | eval(IO.read("app/Gemfile"), binding) 5 | 6 | group :test do 7 | gem 'minitest', '~> 5.11' 8 | end 9 | -------------------------------------------------------------------------------- /CodeAfterExercise1/app/Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gem 'aws-record', '~> 2' 4 | -------------------------------------------------------------------------------- /CodeAfterExercise1/app/posts.rb: -------------------------------------------------------------------------------- 1 | require 'aws-record' 2 | 3 | class Posts 4 | include Aws::Record 5 | set_table_name(ENV["TABLE_NAME"]) 6 | string_attr :post_uuid, hash_key: true 7 | string_attr :title 8 | string_attr :body 9 | epoch_time_attr :created_at 10 | end 11 | -------------------------------------------------------------------------------- /CodeAfterExercise1/app/web_api.rb: -------------------------------------------------------------------------------- 1 | require_relative 'posts' 2 | 3 | class WebApi 4 | class << self 5 | def index(event:,context:) 6 | posts = Posts.scan(limit: 25).page.map { |p| p.to_h } 7 | return { 8 | statusCode: 200, 9 | body: { posts: posts }.to_json 10 | } 11 | end 12 | 13 | def get(event:,context:) 14 | post_id = event["pathParameters"]["uuid"] 15 | post = Posts.find(post_uuid: post_id) 16 | if post 17 | return { 18 | statusCode: 200, 19 | body: { post: post.to_h }.to_json 20 | } 21 | else 22 | return { 23 | statusCode: 404, 24 | body: { error: "Post #{post_id} not found!" }.to_json 25 | } 26 | end 27 | end 28 | 29 | def create(event:,context:) 30 | params = _create_params(event["body"]) 31 | params[:post_uuid] = SecureRandom.uuid 32 | params[:created_at] = Time.now 33 | post = Posts.new(params) 34 | if post.save 35 | return { 36 | statusCode: 200, 37 | body: { post: post.to_h }.to_json 38 | } 39 | else 40 | return { 41 | statusCode: 500, 42 | body: { error: "Failed to create new post." } 43 | } 44 | end 45 | end 46 | 47 | private 48 | def _create_params(body_input) 49 | ret = {} 50 | json = JSON.parse(body_input, symbolize_names: true) 51 | ret[:title] = json[:title] 52 | ret[:body] = json[:body] 53 | ret 54 | end 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /CodeAfterExercise1/packaged.yaml: -------------------------------------------------------------------------------- 1 | AWSTemplateFormatVersion: '2010-09-09' 2 | Description: 'railsconf2019 3 | 4 | Sample SAM Template for railsconf2019 5 | 6 | ' 7 | Globals: 8 | Function: 9 | Timeout: 3 10 | Outputs: 11 | ApiEndpoint: 12 | Description: API Gateway endpoint URL for Prod stage 13 | Value: 14 | Fn::Sub: https://${ServerlessRestApi}.execute-api.${AWS::Region}.amazonaws.com/Prod/ 15 | Resources: 16 | PostsTable: 17 | Properties: 18 | PrimaryKey: 19 | Name: post_uuid 20 | Type: String 21 | Type: AWS::Serverless::SimpleTable 22 | WebApiCreate: 23 | Properties: 24 | CodeUri: s3://alexwood-pdx/96625a3abfccb79264f5c721663c16ed 25 | Environment: 26 | Variables: 27 | TABLE_NAME: 28 | Ref: PostsTable 29 | Events: 30 | ApiCreate: 31 | Properties: 32 | Method: POST 33 | Path: /posts 34 | Type: Api 35 | Handler: web_api.WebApi.create 36 | Policies: 37 | - DynamoDBCrudPolicy: 38 | TableName: 39 | Ref: PostsTable 40 | Runtime: ruby2.5 41 | Type: AWS::Serverless::Function 42 | WebApiGet: 43 | Properties: 44 | CodeUri: s3://alexwood-pdx/2a5c9e65cae4459f719a089e7bbe093a 45 | Environment: 46 | Variables: 47 | TABLE_NAME: 48 | Ref: PostsTable 49 | Events: 50 | ApiGet: 51 | Properties: 52 | Method: GET 53 | Path: /posts/{uuid} 54 | Type: Api 55 | Handler: web_api.WebApi.get 56 | Policies: 57 | - DynamoDBReadPolicy: 58 | TableName: 59 | Ref: PostsTable 60 | Runtime: ruby2.5 61 | Type: AWS::Serverless::Function 62 | WebApiIndex: 63 | Properties: 64 | CodeUri: s3://alexwood-pdx/55049d2e5a8ab676c17192ce0ac1c265 65 | Environment: 66 | Variables: 67 | TABLE_NAME: 68 | Ref: PostsTable 69 | Events: 70 | ApiIndex: 71 | Properties: 72 | Method: GET 73 | Path: /posts 74 | Type: Api 75 | Handler: web_api.WebApi.index 76 | Policies: 77 | - DynamoDBReadPolicy: 78 | TableName: 79 | Ref: PostsTable 80 | Runtime: ruby2.5 81 | Type: AWS::Serverless::Function 82 | Transform: AWS::Serverless-2016-10-31 83 | -------------------------------------------------------------------------------- /CodeAfterExercise1/template.yaml: -------------------------------------------------------------------------------- 1 | AWSTemplateFormatVersion: '2010-09-09' 2 | Transform: AWS::Serverless-2016-10-31 3 | Description: > 4 | railsconf2019 5 | 6 | Sample SAM Template for railsconf2019 7 | 8 | # More info about Globals: https://github.com/awslabs/serverless-application-model/blob/master/docs/globals.rst 9 | Globals: 10 | Function: 11 | Timeout: 3 12 | 13 | 14 | Resources: 15 | WebApiIndex: 16 | Type: AWS::Serverless::Function 17 | Properties: 18 | CodeUri: app/ 19 | Handler: web_api.WebApi.index 20 | Runtime: ruby2.5 21 | Policies: 22 | - DynamoDBReadPolicy: 23 | TableName: !Ref PostsTable 24 | Environment: 25 | Variables: 26 | TABLE_NAME: !Ref PostsTable 27 | Events: 28 | ApiIndex: 29 | Type: Api 30 | Properties: 31 | Path: /posts 32 | Method: GET 33 | WebApiGet: 34 | Type: AWS::Serverless::Function 35 | Properties: 36 | CodeUri: app/ 37 | Handler: web_api.WebApi.get 38 | Runtime: ruby2.5 39 | Policies: 40 | - DynamoDBReadPolicy: 41 | TableName: !Ref PostsTable 42 | Environment: 43 | Variables: 44 | TABLE_NAME: !Ref PostsTable 45 | Events: 46 | ApiGet: 47 | Type: Api 48 | Properties: 49 | Path: /posts/{uuid} 50 | Method: GET 51 | WebApiCreate: 52 | Type: AWS::Serverless::Function 53 | Properties: 54 | CodeUri: app/ 55 | Handler: web_api.WebApi.create 56 | Runtime: ruby2.5 57 | Policies: 58 | - DynamoDBCrudPolicy: 59 | TableName: !Ref PostsTable 60 | Environment: 61 | Variables: 62 | TABLE_NAME: !Ref PostsTable 63 | Events: 64 | ApiCreate: 65 | Type: Api 66 | Properties: 67 | Path: /posts 68 | Method: POST 69 | PostsTable: 70 | Type: AWS::Serverless::SimpleTable 71 | Properties: 72 | PrimaryKey: 73 | Name: post_uuid 74 | Type: String 75 | 76 | Outputs: 77 | ApiEndpoint: 78 | Description: "API Gateway endpoint URL for Prod stage" 79 | Value: !Sub "https://${ServerlessRestApi}.execute-api.${AWS::Region}.amazonaws.com/Prod/" 80 | -------------------------------------------------------------------------------- /CodeAfterExercise1/tests/app/test_web_api.rb: -------------------------------------------------------------------------------- 1 | require 'minitest/autorun' 2 | require 'json' 3 | require_relative '../../app/web_api' 4 | 5 | class WebApiTest < Minitest::Test 6 | def test_index 7 | first_post_time = Time.at(1500000000).utc 8 | second_post_time = Time.at(1550000000).utc 9 | post_list = [ 10 | Posts.new( 11 | post_uuid: "a1", 12 | title: "First Post", 13 | body: "Hello, world!", 14 | created_at: first_post_time 15 | ), 16 | Posts.new( 17 | post_uuid: "b2", 18 | title: "Second Post", 19 | body: "Another post.", 20 | created_at: second_post_time 21 | ) 22 | ] 23 | record_collection = Minitest::Mock.new # mocking an itemcollection 24 | record_collection.expect(:page, post_list, []) 25 | expected_body = { 26 | posts: [ 27 | { 28 | post_uuid: "a1", 29 | title: "First Post", 30 | body: "Hello, world!", 31 | created_at: first_post_time 32 | }, 33 | { 34 | post_uuid: "b2", 35 | title: "Second Post", 36 | body: "Another post.", 37 | created_at: second_post_time 38 | } 39 | ] 40 | }.to_json 41 | Posts.stub(:scan, record_collection) do 42 | actual = WebApi.index(event: {}, context: nil) 43 | assert_equal(expected_body, actual[:body]) 44 | assert_equal(200, actual[:statusCode]) 45 | end 46 | record_collection.verify 47 | end 48 | 49 | def test_get 50 | post_time = Time.at(1500000000).utc 51 | post = Posts.new( 52 | post_uuid: "a1", 53 | title: "First Post", 54 | body: "Hello, world!", 55 | created_at: post_time 56 | ) 57 | expected = { 58 | statusCode: 200, 59 | body: { 60 | post: post.to_h 61 | }.to_json 62 | } 63 | Posts.stub(:find, post) do 64 | actual = WebApi.get( 65 | event: { 66 | "pathParameters" => { 67 | "uuid" => "a1" 68 | } 69 | }, 70 | context: nil 71 | ) 72 | assert_equal(expected, actual) 73 | end 74 | Posts.stub(:find, nil) do 75 | actual = WebApi.get( 76 | event: { 77 | "pathParameters" => { 78 | "uuid" => "a1" 79 | } 80 | }, 81 | context: nil 82 | ) 83 | assert_equal(404, actual[:statusCode]) 84 | end 85 | end 86 | 87 | def test_create 88 | input_event = { 89 | "body" => { 90 | title: "New Post", 91 | body: "Content!" 92 | }.to_json 93 | } 94 | mock = Minitest::Mock.new 95 | mock.expect(:save, true) 96 | mock.expect(:to_h, {}) # we don't check the return value in this test 97 | Posts.stub(:new, mock) do 98 | SecureRandom.stub(:uuid, "abc123") do 99 | Time.stub(:now, Time.at(1500000000)) do 100 | WebApi.create(event: input_event, context: nil) 101 | end 102 | end 103 | end 104 | mock.verify 105 | end 106 | end 107 | -------------------------------------------------------------------------------- /CodeAfterExercise2/.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.gitignore.io/api/osx,linux,python,windows,pycharm,visualstudiocode 3 | 4 | ### Linux ### 5 | *~ 6 | 7 | # temporary files which can be created if a process still has a handle open of a deleted file 8 | .fuse_hidden* 9 | 10 | # KDE directory preferences 11 | .directory 12 | 13 | # Linux trash folder which might appear on any partition or disk 14 | .Trash-* 15 | 16 | # .nfs files are created when an open file is removed but is still being accessed 17 | .nfs* 18 | 19 | ### OSX ### 20 | *.DS_Store 21 | .AppleDouble 22 | .LSOverride 23 | 24 | # Icon must end with two \r 25 | Icon 26 | 27 | # Thumbnails 28 | ._* 29 | 30 | # Files that might appear in the root of a volume 31 | .DocumentRevisions-V100 32 | .fseventsd 33 | .Spotlight-V100 34 | .TemporaryItems 35 | .Trashes 36 | .VolumeIcon.icns 37 | .com.apple.timemachine.donotpresent 38 | 39 | # Directories potentially created on remote AFP share 40 | .AppleDB 41 | .AppleDesktop 42 | Network Trash Folder 43 | Temporary Items 44 | .apdisk 45 | 46 | ### PyCharm ### 47 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm 48 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 49 | 50 | # User-specific stuff: 51 | .idea/**/workspace.xml 52 | .idea/**/tasks.xml 53 | .idea/dictionaries 54 | 55 | # Sensitive or high-churn files: 56 | .idea/**/dataSources/ 57 | .idea/**/dataSources.ids 58 | .idea/**/dataSources.xml 59 | .idea/**/dataSources.local.xml 60 | .idea/**/sqlDataSources.xml 61 | .idea/**/dynamic.xml 62 | .idea/**/uiDesigner.xml 63 | 64 | # Gradle: 65 | .idea/**/gradle.xml 66 | .idea/**/libraries 67 | 68 | # CMake 69 | cmake-build-debug/ 70 | 71 | # Mongo Explorer plugin: 72 | .idea/**/mongoSettings.xml 73 | 74 | ## File-based project format: 75 | *.iws 76 | 77 | ## Plugin-specific files: 78 | 79 | # IntelliJ 80 | /out/ 81 | 82 | # mpeltonen/sbt-idea plugin 83 | .idea_modules/ 84 | 85 | # JIRA plugin 86 | atlassian-ide-plugin.xml 87 | 88 | # Cursive Clojure plugin 89 | .idea/replstate.xml 90 | 91 | # Ruby plugin and RubyMine 92 | /.rakeTasks 93 | 94 | # Crashlytics plugin (for Android Studio and IntelliJ) 95 | com_crashlytics_export_strings.xml 96 | crashlytics.properties 97 | crashlytics-build.properties 98 | fabric.properties 99 | 100 | ### PyCharm Patch ### 101 | # Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 102 | 103 | # *.iml 104 | # modules.xml 105 | # .idea/misc.xml 106 | # *.ipr 107 | 108 | # Sonarlint plugin 109 | .idea/sonarlint 110 | 111 | ### Python ### 112 | # Byte-compiled / optimized / DLL files 113 | __pycache__/ 114 | *.py[cod] 115 | *$py.class 116 | 117 | # C extensions 118 | *.so 119 | 120 | # Distribution / packaging 121 | .Python 122 | build/ 123 | develop-eggs/ 124 | dist/ 125 | downloads/ 126 | eggs/ 127 | .eggs/ 128 | lib/ 129 | lib64/ 130 | parts/ 131 | sdist/ 132 | var/ 133 | wheels/ 134 | *.egg-info/ 135 | .installed.cfg 136 | *.egg 137 | 138 | # PyInstaller 139 | # Usually these files are written by a python script from a template 140 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 141 | *.manifest 142 | *.spec 143 | 144 | # Installer logs 145 | pip-log.txt 146 | pip-delete-this-directory.txt 147 | 148 | # Unit test / coverage reports 149 | htmlcov/ 150 | .tox/ 151 | .coverage 152 | .coverage.* 153 | .cache 154 | .pytest_cache/ 155 | nosetests.xml 156 | coverage.xml 157 | *.cover 158 | .hypothesis/ 159 | 160 | # Translations 161 | *.mo 162 | *.pot 163 | 164 | # Flask stuff: 165 | instance/ 166 | .webassets-cache 167 | 168 | # Scrapy stuff: 169 | .scrapy 170 | 171 | # Sphinx documentation 172 | docs/_build/ 173 | 174 | # PyBuilder 175 | target/ 176 | 177 | # Jupyter Notebook 178 | .ipynb_checkpoints 179 | 180 | # pyenv 181 | .python-version 182 | 183 | # celery beat schedule file 184 | celerybeat-schedule.* 185 | 186 | # SageMath parsed files 187 | *.sage.py 188 | 189 | # Environments 190 | .env 191 | .venv 192 | env/ 193 | venv/ 194 | ENV/ 195 | env.bak/ 196 | venv.bak/ 197 | 198 | # Spyder project settings 199 | .spyderproject 200 | .spyproject 201 | 202 | # Rope project settings 203 | .ropeproject 204 | 205 | # mkdocs documentation 206 | /site 207 | 208 | # mypy 209 | .mypy_cache/ 210 | 211 | ### VisualStudioCode ### 212 | .vscode/* 213 | !.vscode/settings.json 214 | !.vscode/tasks.json 215 | !.vscode/launch.json 216 | !.vscode/extensions.json 217 | .history 218 | 219 | ### Windows ### 220 | # Windows thumbnail cache files 221 | Thumbs.db 222 | ehthumbs.db 223 | ehthumbs_vista.db 224 | 225 | # Folder config file 226 | Desktop.ini 227 | 228 | # Recycle Bin used on file shares 229 | $RECYCLE.BIN/ 230 | 231 | # Windows Installer files 232 | *.cab 233 | *.msi 234 | *.msm 235 | *.msp 236 | 237 | # Windows shortcuts 238 | *.lnk 239 | 240 | # Build folder 241 | 242 | */build/* 243 | 244 | # End of https://www.gitignore.io/api/osx,linux,python,windows,pycharm,visualstudiocode 245 | 246 | # Don't put ZIP files into source control 247 | *.zip -------------------------------------------------------------------------------- /CodeAfterExercise2/Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | # Load app Gemfile dependencies 4 | eval(IO.read("app/Gemfile"), binding) 5 | 6 | group :test do 7 | gem 'minitest', '~> 5.11' 8 | end 9 | -------------------------------------------------------------------------------- /CodeAfterExercise2/app/Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gem 'aws-record', '~> 2' 4 | -------------------------------------------------------------------------------- /CodeAfterExercise2/app/event_handlers.rb: -------------------------------------------------------------------------------- 1 | require_relative 'posts' 2 | require 'json' 3 | 4 | class EventHandlers 5 | class << self 6 | def delete_all_posts(event:,context:) 7 | event["Records"].each do |record| 8 | if record["body"] == "DELETE_ALL" 9 | count = 0 10 | begin 11 | Posts.scan.each do |post| 12 | post.delete! 13 | count += 1 14 | end 15 | puts "[INFO] Deleted #{count} posts." 16 | rescue Aws::DynamoDB::Errors => e 17 | puts "[ERROR] Raised #{e.class} after deleting #{count} entries." 18 | raise(e) 19 | end 20 | else 21 | puts "[ERROR] Unsupported queue event: #{record.to_json}" 22 | raise StandardError.new( 23 | "Unsupported queue command: #{record["body"]}" 24 | ) 25 | end 26 | end 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /CodeAfterExercise2/app/posts.rb: -------------------------------------------------------------------------------- 1 | require 'aws-record' 2 | 3 | class Posts 4 | include Aws::Record 5 | set_table_name(ENV["TABLE_NAME"]) 6 | string_attr :post_uuid, hash_key: true 7 | string_attr :title 8 | string_attr :body 9 | epoch_time_attr :created_at 10 | end 11 | -------------------------------------------------------------------------------- /CodeAfterExercise2/app/web_api.rb: -------------------------------------------------------------------------------- 1 | require_relative 'posts' 2 | 3 | class WebApi 4 | class << self 5 | def index(event:,context:) 6 | posts = Posts.scan(limit: 25).page.map { |p| p.to_h } 7 | return { 8 | statusCode: 200, 9 | body: { posts: posts }.to_json 10 | } 11 | end 12 | 13 | def get(event:,context:) 14 | post_id = event["pathParameters"]["uuid"] 15 | post = Posts.find(post_uuid: post_id) 16 | if post 17 | return { 18 | statusCode: 200, 19 | body: { post: post.to_h }.to_json 20 | } 21 | else 22 | return { 23 | statusCode: 404, 24 | body: { error: "Post #{post_id} not found!" }.to_json 25 | } 26 | end 27 | end 28 | 29 | def create(event:,context:) 30 | params = _create_params(event["body"]) 31 | params[:post_uuid] = SecureRandom.uuid 32 | params[:created_at] = Time.now 33 | post = Posts.new(params) 34 | if post.save 35 | return { 36 | statusCode: 200, 37 | body: { post: post.to_h }.to_json 38 | } 39 | else 40 | return { 41 | statusCode: 500, 42 | body: { error: "Failed to create new post." } 43 | } 44 | end 45 | end 46 | 47 | def delete_all(event:,context:) 48 | _sqs_client.send_message( 49 | queue_url: _sqs_queue_url, 50 | message_body: "DELETE_ALL" 51 | ) 52 | return { 53 | statusCode: 204 54 | } 55 | end 56 | 57 | private 58 | def _create_params(body_input) 59 | ret = {} 60 | json = JSON.parse(body_input, symbolize_names: true) 61 | ret[:title] = json[:title] 62 | ret[:body] = json[:body] 63 | ret 64 | end 65 | 66 | def _sqs_client 67 | require 'aws-sdk-sqs' 68 | @@sqs_client ||= Aws::SQS::Client.new 69 | @@sqs_client 70 | end 71 | 72 | def _sqs_queue_url 73 | ENV["SQS_QUEUE_URL"] 74 | end 75 | end 76 | end 77 | -------------------------------------------------------------------------------- /CodeAfterExercise2/packaged.yaml: -------------------------------------------------------------------------------- 1 | AWSTemplateFormatVersion: '2010-09-09' 2 | Description: 'railsconf2019 3 | 4 | Sample SAM Template for railsconf2019 5 | 6 | ' 7 | Globals: 8 | Function: 9 | Timeout: 3 10 | Outputs: 11 | ApiEndpoint: 12 | Description: API Gateway endpoint URL for Prod stage 13 | Value: 14 | Fn::Sub: https://${ServerlessRestApi}.execute-api.${AWS::Region}.amazonaws.com/Prod/ 15 | Resources: 16 | DeleteAllEventHandler: 17 | Properties: 18 | CodeUri: s3://alexwood-pdx/ff01e20874dc6401643c7321a7b81022 19 | Environment: 20 | Variables: 21 | TABLE_NAME: 22 | Ref: PostsTable 23 | Events: 24 | QueueEvent: 25 | Properties: 26 | Queue: 27 | Fn::GetAtt: 28 | - DeletePostQueue 29 | - Arn 30 | Type: SQS 31 | Handler: event_handlers.EventHandlers.delete_all_posts 32 | Policies: 33 | - DynamoDBCrudPolicy: 34 | TableName: 35 | Ref: PostsTable 36 | Runtime: ruby2.5 37 | Type: AWS::Serverless::Function 38 | DeleteAllHandler: 39 | Properties: 40 | CodeUri: s3://alexwood-pdx/e1bc4f2523cbb23cbec2ec0daea62032 41 | Environment: 42 | Variables: 43 | SQS_QUEUE_URL: 44 | Ref: DeletePostQueue 45 | Events: 46 | DeleteAllApi: 47 | Properties: 48 | Method: DELETE 49 | Path: /posts 50 | Type: Api 51 | Handler: web_api.WebApi.delete_all 52 | Policies: 53 | SQSSendMessagePolicy: 54 | QueueName: 55 | Fn::GetAtt: 56 | - DeletePostQueue 57 | - QueueName 58 | Runtime: ruby2.5 59 | Type: AWS::Serverless::Function 60 | DeletePostQueue: 61 | Type: AWS::SQS::Queue 62 | PostsTable: 63 | Properties: 64 | PrimaryKey: 65 | Name: post_uuid 66 | Type: String 67 | Type: AWS::Serverless::SimpleTable 68 | WebApiCreate: 69 | Properties: 70 | CodeUri: s3://alexwood-pdx/5eb5f55e167efe2f5e466ca7a1e31040 71 | Environment: 72 | Variables: 73 | TABLE_NAME: 74 | Ref: PostsTable 75 | Events: 76 | ApiCreate: 77 | Properties: 78 | Method: POST 79 | Path: /posts 80 | Type: Api 81 | Handler: web_api.WebApi.create 82 | Policies: 83 | - DynamoDBCrudPolicy: 84 | TableName: 85 | Ref: PostsTable 86 | Runtime: ruby2.5 87 | Type: AWS::Serverless::Function 88 | WebApiGet: 89 | Properties: 90 | CodeUri: s3://alexwood-pdx/5f11a6b8e5b7e4f38bbec303f9f2c068 91 | Environment: 92 | Variables: 93 | TABLE_NAME: 94 | Ref: PostsTable 95 | Events: 96 | ApiGet: 97 | Properties: 98 | Method: GET 99 | Path: /posts/{uuid} 100 | Type: Api 101 | Handler: web_api.WebApi.get 102 | Policies: 103 | - DynamoDBReadPolicy: 104 | TableName: 105 | Ref: PostsTable 106 | Runtime: ruby2.5 107 | Type: AWS::Serverless::Function 108 | WebApiIndex: 109 | Properties: 110 | CodeUri: s3://alexwood-pdx/e4e0b8abbb8d478d0f91c12456c5d38a 111 | Environment: 112 | Variables: 113 | TABLE_NAME: 114 | Ref: PostsTable 115 | Events: 116 | ApiIndex: 117 | Properties: 118 | Method: GET 119 | Path: /posts 120 | Type: Api 121 | Handler: web_api.WebApi.index 122 | Policies: 123 | - DynamoDBReadPolicy: 124 | TableName: 125 | Ref: PostsTable 126 | Runtime: ruby2.5 127 | Type: AWS::Serverless::Function 128 | Transform: AWS::Serverless-2016-10-31 129 | -------------------------------------------------------------------------------- /CodeAfterExercise2/template.yaml: -------------------------------------------------------------------------------- 1 | AWSTemplateFormatVersion: '2010-09-09' 2 | Transform: AWS::Serverless-2016-10-31 3 | Description: > 4 | railsconf2019 5 | 6 | Sample SAM Template for railsconf2019 7 | 8 | # More info about Globals: https://github.com/awslabs/serverless-application-model/blob/master/docs/globals.rst 9 | Globals: 10 | Function: 11 | Timeout: 3 12 | 13 | 14 | Resources: 15 | WebApiIndex: 16 | Type: AWS::Serverless::Function 17 | Properties: 18 | CodeUri: app/ 19 | Handler: web_api.WebApi.index 20 | Runtime: ruby2.5 21 | Policies: 22 | - DynamoDBReadPolicy: 23 | TableName: !Ref PostsTable 24 | Environment: 25 | Variables: 26 | TABLE_NAME: !Ref PostsTable 27 | Events: 28 | ApiIndex: 29 | Type: Api 30 | Properties: 31 | Path: /posts 32 | Method: GET 33 | WebApiGet: 34 | Type: AWS::Serverless::Function 35 | Properties: 36 | CodeUri: app/ 37 | Handler: web_api.WebApi.get 38 | Runtime: ruby2.5 39 | Policies: 40 | - DynamoDBReadPolicy: 41 | TableName: !Ref PostsTable 42 | Environment: 43 | Variables: 44 | TABLE_NAME: !Ref PostsTable 45 | Events: 46 | ApiGet: 47 | Type: Api 48 | Properties: 49 | Path: /posts/{uuid} 50 | Method: GET 51 | WebApiCreate: 52 | Type: AWS::Serverless::Function 53 | Properties: 54 | CodeUri: app/ 55 | Handler: web_api.WebApi.create 56 | Runtime: ruby2.5 57 | Policies: 58 | - DynamoDBCrudPolicy: 59 | TableName: !Ref PostsTable 60 | Environment: 61 | Variables: 62 | TABLE_NAME: !Ref PostsTable 63 | Events: 64 | ApiCreate: 65 | Type: Api 66 | Properties: 67 | Path: /posts 68 | Method: POST 69 | DeleteAllEventHandler: 70 | Type: AWS::Serverless::Function 71 | Properties: 72 | CodeUri: app/ 73 | Handler: event_handlers.EventHandlers.delete_all_posts 74 | Runtime: ruby2.5 75 | Policies: 76 | - DynamoDBCrudPolicy: 77 | TableName: !Ref PostsTable 78 | Environment: 79 | Variables: 80 | TABLE_NAME: !Ref PostsTable 81 | Events: 82 | QueueEvent: 83 | Type: SQS 84 | Properties: 85 | Queue: !GetAtt DeletePostQueue.Arn 86 | DeleteAllHandler: 87 | Type: AWS::Serverless::Function 88 | Properties: 89 | CodeUri: app/ 90 | Handler: web_api.WebApi.delete_all 91 | Runtime: ruby2.5 92 | Environment: 93 | Variables: 94 | SQS_QUEUE_URL: !Ref DeletePostQueue 95 | Policies: 96 | SQSSendMessagePolicy: 97 | QueueName: !GetAtt DeletePostQueue.QueueName 98 | Events: 99 | DeleteAllApi: 100 | Type: Api 101 | Properties: 102 | Path: /posts 103 | Method: DELETE 104 | DeletePostQueue: 105 | Type: AWS::SQS::Queue 106 | PostsTable: 107 | Type: AWS::Serverless::SimpleTable 108 | Properties: 109 | PrimaryKey: 110 | Name: post_uuid 111 | Type: String 112 | 113 | Outputs: 114 | ApiEndpoint: 115 | Description: "API Gateway endpoint URL for Prod stage" 116 | Value: !Sub "https://${ServerlessRestApi}.execute-api.${AWS::Region}.amazonaws.com/Prod/" 117 | -------------------------------------------------------------------------------- /CodeAfterExercise2/tests/app/test_event_handlers.rb: -------------------------------------------------------------------------------- 1 | require 'minitest/autorun' 2 | require_relative '../../app/event_handlers' 3 | 4 | class EventHandlersTest < Minitest::Test 5 | def test_delete_all_posts 6 | input_event = { 7 | "Records" => [ 8 | { 9 | "messageId" => SecureRandom.uuid, 10 | "body" => "DELETE_ALL", 11 | "md5OfBody" => "319f263fe809cba0eb00f8977a972740" 12 | } 13 | ] 14 | } 15 | mock_post_a = Minitest::Mock.new 16 | mock_post_b = Minitest::Mock.new 17 | mock_post_c = Minitest::Mock.new 18 | post_list = [ 19 | mock_post_a, 20 | mock_post_b, 21 | mock_post_c 22 | ] 23 | mock_post_a.expect(:delete!, nil, []) 24 | mock_post_b.expect(:delete!, nil, []) 25 | mock_post_c.expect(:delete!, nil, []) 26 | Posts.stub(:scan, post_list) do 27 | EventHandlers.delete_all_posts(event: input_event, context: nil) 28 | end 29 | mock_post_a.verify 30 | mock_post_b.verify 31 | mock_post_c.verify 32 | end 33 | 34 | def test_delete_all_posts_bad_event 35 | input_event = { 36 | "Records" => [ 37 | { 38 | "messageId" => SecureRandom.uuid, 39 | "body" => "BAD_MESSAGE", 40 | "md5OfBody" => "6af3db524c14f32b6f183d51c8d04e8a" 41 | } 42 | ] 43 | } 44 | assert_raises(StandardError) { 45 | EventHandlers.delete_all_posts(event: input_event, context: nil) 46 | } 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /CodeAfterExercise2/tests/app/test_web_api.rb: -------------------------------------------------------------------------------- 1 | require 'minitest/autorun' 2 | require 'json' 3 | require_relative '../../app/web_api' 4 | 5 | class WebApiTest < Minitest::Test 6 | def test_index 7 | first_post_time = Time.at(1500000000).utc 8 | second_post_time = Time.at(1550000000).utc 9 | post_list = [ 10 | Posts.new( 11 | post_uuid: "a1", 12 | title: "First Post", 13 | body: "Hello, world!", 14 | created_at: first_post_time 15 | ), 16 | Posts.new( 17 | post_uuid: "b2", 18 | title: "Second Post", 19 | body: "Another post.", 20 | created_at: second_post_time 21 | ) 22 | ] 23 | record_collection = Minitest::Mock.new # mocking an itemcollection 24 | record_collection.expect(:page, post_list, []) 25 | expected_body = { 26 | posts: [ 27 | { 28 | post_uuid: "a1", 29 | title: "First Post", 30 | body: "Hello, world!", 31 | created_at: first_post_time 32 | }, 33 | { 34 | post_uuid: "b2", 35 | title: "Second Post", 36 | body: "Another post.", 37 | created_at: second_post_time 38 | } 39 | ] 40 | }.to_json 41 | Posts.stub(:scan, record_collection) do 42 | actual = WebApi.index(event: {}, context: nil) 43 | assert_equal(expected_body, actual[:body]) 44 | assert_equal(200, actual[:statusCode]) 45 | end 46 | record_collection.verify 47 | end 48 | 49 | def test_get 50 | post_time = Time.at(1500000000).utc 51 | post = Posts.new( 52 | post_uuid: "a1", 53 | title: "First Post", 54 | body: "Hello, world!", 55 | created_at: post_time 56 | ) 57 | expected = { 58 | statusCode: 200, 59 | body: { 60 | post: post.to_h 61 | }.to_json 62 | } 63 | Posts.stub(:find, post) do 64 | actual = WebApi.get( 65 | event: { 66 | "pathParameters" => { 67 | "uuid" => "a1" 68 | } 69 | }, 70 | context: nil 71 | ) 72 | assert_equal(expected, actual) 73 | end 74 | Posts.stub(:find, nil) do 75 | actual = WebApi.get( 76 | event: { 77 | "pathParameters" => { 78 | "uuid" => "a1" 79 | } 80 | }, 81 | context: nil 82 | ) 83 | assert_equal(404, actual[:statusCode]) 84 | end 85 | end 86 | 87 | def test_create 88 | input_event = { 89 | "body" => { 90 | title: "New Post", 91 | body: "Content!" 92 | }.to_json 93 | } 94 | mock = Minitest::Mock.new 95 | mock.expect(:save, true) 96 | mock.expect(:to_h, {}) # we don't check the return value in this test 97 | Posts.stub(:new, mock) do 98 | SecureRandom.stub(:uuid, "abc123") do 99 | Time.stub(:now, Time.at(1500000000)) do 100 | WebApi.create(event: input_event, context: nil) 101 | end 102 | end 103 | end 104 | mock.verify 105 | end 106 | end 107 | -------------------------------------------------------------------------------- /CodeAfterExercise3/.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.gitignore.io/api/osx,linux,python,windows,pycharm,visualstudiocode 3 | 4 | ### Linux ### 5 | *~ 6 | 7 | # temporary files which can be created if a process still has a handle open of a deleted file 8 | .fuse_hidden* 9 | 10 | # KDE directory preferences 11 | .directory 12 | 13 | # Linux trash folder which might appear on any partition or disk 14 | .Trash-* 15 | 16 | # .nfs files are created when an open file is removed but is still being accessed 17 | .nfs* 18 | 19 | ### OSX ### 20 | *.DS_Store 21 | .AppleDouble 22 | .LSOverride 23 | 24 | # Icon must end with two \r 25 | Icon 26 | 27 | # Thumbnails 28 | ._* 29 | 30 | # Files that might appear in the root of a volume 31 | .DocumentRevisions-V100 32 | .fseventsd 33 | .Spotlight-V100 34 | .TemporaryItems 35 | .Trashes 36 | .VolumeIcon.icns 37 | .com.apple.timemachine.donotpresent 38 | 39 | # Directories potentially created on remote AFP share 40 | .AppleDB 41 | .AppleDesktop 42 | Network Trash Folder 43 | Temporary Items 44 | .apdisk 45 | 46 | ### PyCharm ### 47 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm 48 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 49 | 50 | # User-specific stuff: 51 | .idea/**/workspace.xml 52 | .idea/**/tasks.xml 53 | .idea/dictionaries 54 | 55 | # Sensitive or high-churn files: 56 | .idea/**/dataSources/ 57 | .idea/**/dataSources.ids 58 | .idea/**/dataSources.xml 59 | .idea/**/dataSources.local.xml 60 | .idea/**/sqlDataSources.xml 61 | .idea/**/dynamic.xml 62 | .idea/**/uiDesigner.xml 63 | 64 | # Gradle: 65 | .idea/**/gradle.xml 66 | .idea/**/libraries 67 | 68 | # CMake 69 | cmake-build-debug/ 70 | 71 | # Mongo Explorer plugin: 72 | .idea/**/mongoSettings.xml 73 | 74 | ## File-based project format: 75 | *.iws 76 | 77 | ## Plugin-specific files: 78 | 79 | # IntelliJ 80 | /out/ 81 | 82 | # mpeltonen/sbt-idea plugin 83 | .idea_modules/ 84 | 85 | # JIRA plugin 86 | atlassian-ide-plugin.xml 87 | 88 | # Cursive Clojure plugin 89 | .idea/replstate.xml 90 | 91 | # Ruby plugin and RubyMine 92 | /.rakeTasks 93 | 94 | # Crashlytics plugin (for Android Studio and IntelliJ) 95 | com_crashlytics_export_strings.xml 96 | crashlytics.properties 97 | crashlytics-build.properties 98 | fabric.properties 99 | 100 | ### PyCharm Patch ### 101 | # Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 102 | 103 | # *.iml 104 | # modules.xml 105 | # .idea/misc.xml 106 | # *.ipr 107 | 108 | # Sonarlint plugin 109 | .idea/sonarlint 110 | 111 | ### Python ### 112 | # Byte-compiled / optimized / DLL files 113 | __pycache__/ 114 | *.py[cod] 115 | *$py.class 116 | 117 | # C extensions 118 | *.so 119 | 120 | # Distribution / packaging 121 | .Python 122 | build/ 123 | develop-eggs/ 124 | dist/ 125 | downloads/ 126 | eggs/ 127 | .eggs/ 128 | lib/ 129 | lib64/ 130 | parts/ 131 | sdist/ 132 | var/ 133 | wheels/ 134 | *.egg-info/ 135 | .installed.cfg 136 | *.egg 137 | 138 | # PyInstaller 139 | # Usually these files are written by a python script from a template 140 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 141 | *.manifest 142 | *.spec 143 | 144 | # Installer logs 145 | pip-log.txt 146 | pip-delete-this-directory.txt 147 | 148 | # Unit test / coverage reports 149 | htmlcov/ 150 | .tox/ 151 | .coverage 152 | .coverage.* 153 | .cache 154 | .pytest_cache/ 155 | nosetests.xml 156 | coverage.xml 157 | *.cover 158 | .hypothesis/ 159 | 160 | # Translations 161 | *.mo 162 | *.pot 163 | 164 | # Flask stuff: 165 | instance/ 166 | .webassets-cache 167 | 168 | # Scrapy stuff: 169 | .scrapy 170 | 171 | # Sphinx documentation 172 | docs/_build/ 173 | 174 | # PyBuilder 175 | target/ 176 | 177 | # Jupyter Notebook 178 | .ipynb_checkpoints 179 | 180 | # pyenv 181 | .python-version 182 | 183 | # celery beat schedule file 184 | celerybeat-schedule.* 185 | 186 | # SageMath parsed files 187 | *.sage.py 188 | 189 | # Environments 190 | .env 191 | .venv 192 | env/ 193 | venv/ 194 | ENV/ 195 | env.bak/ 196 | venv.bak/ 197 | 198 | # Spyder project settings 199 | .spyderproject 200 | .spyproject 201 | 202 | # Rope project settings 203 | .ropeproject 204 | 205 | # mkdocs documentation 206 | /site 207 | 208 | # mypy 209 | .mypy_cache/ 210 | 211 | ### VisualStudioCode ### 212 | .vscode/* 213 | !.vscode/settings.json 214 | !.vscode/tasks.json 215 | !.vscode/launch.json 216 | !.vscode/extensions.json 217 | .history 218 | 219 | ### Windows ### 220 | # Windows thumbnail cache files 221 | Thumbs.db 222 | ehthumbs.db 223 | ehthumbs_vista.db 224 | 225 | # Folder config file 226 | Desktop.ini 227 | 228 | # Recycle Bin used on file shares 229 | $RECYCLE.BIN/ 230 | 231 | # Windows Installer files 232 | *.cab 233 | *.msi 234 | *.msm 235 | *.msp 236 | 237 | # Windows shortcuts 238 | *.lnk 239 | 240 | # Build folder 241 | 242 | */build/* 243 | 244 | # End of https://www.gitignore.io/api/osx,linux,python,windows,pycharm,visualstudiocode 245 | 246 | # Don't put ZIP files into source control 247 | *.zip -------------------------------------------------------------------------------- /CodeAfterExercise3/Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | # Load app Gemfile dependencies 4 | eval(IO.read("app/Gemfile"), binding) 5 | 6 | group :test do 7 | gem 'minitest', '~> 5.11' 8 | end 9 | -------------------------------------------------------------------------------- /CodeAfterExercise3/app/Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gem 'aws-record', '~> 2' 4 | -------------------------------------------------------------------------------- /CodeAfterExercise3/app/event_handlers.rb: -------------------------------------------------------------------------------- 1 | require_relative 'posts' 2 | require 'json' 3 | 4 | class EventHandlers 5 | class << self 6 | def delete_all_posts(event:,context:) 7 | event["Records"].each do |record| 8 | if record["body"] == "DELETE_ALL" 9 | count = 0 10 | begin 11 | Posts.scan.each do |post| 12 | post.delete! 13 | count += 1 14 | end 15 | puts "[INFO] Deleted #{count} posts." 16 | rescue Aws::DynamoDB::Errors => e 17 | puts "[ERROR] Raised #{e.class} after deleting #{count} entries." 18 | raise(e) 19 | end 20 | else 21 | puts "[ERROR] Unsupported queue event: #{record.to_json}" 22 | raise StandardError.new( 23 | "Unsupported queue command: #{record["body"]}" 24 | ) 25 | end 26 | end 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /CodeAfterExercise3/app/posts.rb: -------------------------------------------------------------------------------- 1 | require 'aws-record' 2 | 3 | class Posts 4 | include Aws::Record 5 | set_table_name(ENV["TABLE_NAME"]) 6 | string_attr :post_uuid, hash_key: true 7 | string_attr :title 8 | string_attr :body 9 | epoch_time_attr :created_at 10 | end 11 | -------------------------------------------------------------------------------- /CodeAfterExercise3/app/web_api.rb: -------------------------------------------------------------------------------- 1 | require_relative 'posts' 2 | 3 | class WebApi 4 | class << self 5 | def index(event:,context:) 6 | posts = Posts.scan(limit: 25).page.map { |p| p.to_h } 7 | return { 8 | statusCode: 200, 9 | body: { posts: posts }.to_json 10 | } 11 | end 12 | 13 | def get(event:,context:) 14 | post_id = event["pathParameters"]["uuid"] 15 | post = Posts.find(post_uuid: post_id) 16 | if post 17 | return { 18 | statusCode: 200, 19 | body: { post: post.to_h }.to_json 20 | } 21 | else 22 | return { 23 | statusCode: 404, 24 | body: { error: "Post #{post_id} not found!" }.to_json 25 | } 26 | end 27 | end 28 | 29 | def create(event:,context:) 30 | params = _create_params(event["body"]) 31 | params[:post_uuid] = SecureRandom.uuid 32 | params[:created_at] = Time.now 33 | post = Posts.new(params) 34 | if post.save 35 | return { 36 | statusCode: 200, 37 | body: { post: post.to_h }.to_json 38 | } 39 | else 40 | return { 41 | statusCode: 500, 42 | body: { error: "Failed to create new post." } 43 | } 44 | end 45 | end 46 | 47 | def delete_all(event:,context:) 48 | _sqs_client.send_message( 49 | queue_url: _sqs_queue_url, 50 | message_body: "DELETE_ALL" 51 | ) 52 | return { 53 | statusCode: 204 54 | } 55 | end 56 | 57 | private 58 | def _create_params(body_input) 59 | ret = {} 60 | json = JSON.parse(body_input, symbolize_names: true) 61 | ret[:title] = json[:title] 62 | ret[:body] = json[:body] 63 | ret 64 | end 65 | 66 | def _sqs_client 67 | require 'aws-sdk-sqs' 68 | @@sqs_client ||= Aws::SQS::Client.new 69 | @@sqs_client 70 | end 71 | 72 | def _sqs_queue_url 73 | ENV["SQS_QUEUE_URL"] 74 | end 75 | end 76 | end 77 | -------------------------------------------------------------------------------- /CodeAfterExercise3/packaged.yaml: -------------------------------------------------------------------------------- 1 | AWSTemplateFormatVersion: '2010-09-09' 2 | Description: 'railsconf2019 3 | 4 | Sample SAM Template for railsconf2019 5 | 6 | ' 7 | Globals: 8 | Function: 9 | Timeout: 3 10 | Outputs: 11 | ApiEndpoint: 12 | Description: API Gateway endpoint URL for Prod stage 13 | Value: 14 | Fn::Sub: https://${ServerlessRestApi}.execute-api.${AWS::Region}.amazonaws.com/Prod/ 15 | Resources: 16 | DeleteAllEventHandler: 17 | Properties: 18 | CodeUri: s3://alexwood-pdx/b3465b2b2b91339f252bed0da8afe9a9 19 | Environment: 20 | Variables: 21 | TABLE_NAME: 22 | Ref: PostsTable 23 | Events: 24 | QueueEvent: 25 | Properties: 26 | Queue: 27 | Fn::GetAtt: 28 | - DeletePostQueue 29 | - Arn 30 | Type: SQS 31 | Handler: event_handlers.EventHandlers.delete_all_posts 32 | Policies: 33 | - DynamoDBCrudPolicy: 34 | TableName: 35 | Ref: PostsTable 36 | Runtime: ruby2.5 37 | Type: AWS::Serverless::Function 38 | DeleteAllHandler: 39 | Properties: 40 | CodeUri: s3://alexwood-pdx/f5a224e5f8583387e404c42793802b53 41 | Environment: 42 | Variables: 43 | SQS_QUEUE_URL: 44 | Ref: DeletePostQueue 45 | Events: 46 | DeleteAllApi: 47 | Properties: 48 | Method: DELETE 49 | Path: /posts 50 | Type: Api 51 | Handler: web_api.WebApi.delete_all 52 | Policies: 53 | SQSSendMessagePolicy: 54 | QueueName: 55 | Fn::GetAtt: 56 | - DeletePostQueue 57 | - QueueName 58 | Runtime: ruby2.5 59 | Type: AWS::Serverless::Function 60 | DeletePostDLQAlarm: 61 | Properties: 62 | ComparisonOperator: GreaterThanOrEqualToThreshold 63 | Dimensions: 64 | - Name: QueueName 65 | Value: 66 | Fn::GetAtt: 67 | - DeletePostDeadLetterQueue 68 | - QueueName 69 | EvaluationPeriods: 5 70 | MetricName: ApproximateNumberOfMessagesVisible 71 | Namespace: AWS/SQS 72 | Period: 60 73 | Statistic: Sum 74 | Threshold: 1 75 | TreatMissingData: breaching 76 | Type: AWS::CloudWatch::Alarm 77 | DeletePostDeadLetterQueue: 78 | Type: AWS::SQS::Queue 79 | DeletePostQueue: 80 | Properties: 81 | RedrivePolicy: 82 | deadLetterTargetArn: 83 | Fn::GetAtt: 84 | - DeletePostDeadLetterQueue 85 | - Arn 86 | maxReceiveCount: 5 87 | Type: AWS::SQS::Queue 88 | PostsTable: 89 | Properties: 90 | PrimaryKey: 91 | Name: post_uuid 92 | Type: String 93 | Type: AWS::Serverless::SimpleTable 94 | WebApiCreate: 95 | Properties: 96 | CodeUri: s3://alexwood-pdx/13ec3076d2ba8674c57f50ca3156d2f3 97 | Environment: 98 | Variables: 99 | TABLE_NAME: 100 | Ref: PostsTable 101 | Events: 102 | ApiCreate: 103 | Properties: 104 | Method: POST 105 | Path: /posts 106 | Type: Api 107 | Handler: web_api.WebApi.create 108 | Policies: 109 | - DynamoDBCrudPolicy: 110 | TableName: 111 | Ref: PostsTable 112 | Runtime: ruby2.5 113 | Type: AWS::Serverless::Function 114 | WebApiGet: 115 | Properties: 116 | CodeUri: s3://alexwood-pdx/3a093bb0b1b21b14072840fb70a2d95c 117 | Environment: 118 | Variables: 119 | TABLE_NAME: 120 | Ref: PostsTable 121 | Events: 122 | ApiGet: 123 | Properties: 124 | Method: GET 125 | Path: /posts/{uuid} 126 | Type: Api 127 | Handler: web_api.WebApi.get 128 | Policies: 129 | - DynamoDBReadPolicy: 130 | TableName: 131 | Ref: PostsTable 132 | Runtime: ruby2.5 133 | Type: AWS::Serverless::Function 134 | WebApiIndex: 135 | Properties: 136 | CodeUri: s3://alexwood-pdx/1d91fc71249f5c230610be61f27be9e6 137 | Environment: 138 | Variables: 139 | TABLE_NAME: 140 | Ref: PostsTable 141 | Events: 142 | ApiIndex: 143 | Properties: 144 | Method: GET 145 | Path: /posts 146 | Type: Api 147 | Handler: web_api.WebApi.index 148 | Policies: 149 | - DynamoDBReadPolicy: 150 | TableName: 151 | Ref: PostsTable 152 | Runtime: ruby2.5 153 | Type: AWS::Serverless::Function 154 | WebApiIndexErrorAlarm: 155 | Properties: 156 | ComparisonOperator: GreaterThanOrEqualToThreshold 157 | Dimensions: 158 | - Name: FunctionName 159 | Value: 160 | Ref: WebApiIndex 161 | EvaluationPeriods: 3 162 | MetricName: Errors 163 | Namespace: AWS/Lambda 164 | Period: 60 165 | Statistic: Sum 166 | Threshold: 1 167 | TreatMissingData: missing 168 | Type: AWS::CloudWatch::Alarm 169 | WebApiIndexLatencyP50Alarm: 170 | Properties: 171 | ComparisonOperator: GreaterThanOrEqualToThreshold 172 | Dimensions: 173 | - Name: FunctionName 174 | Value: 175 | Ref: WebApiIndex 176 | EvaluationPeriods: 3 177 | ExtendedStatistic: p50 178 | MetricName: Duration 179 | Namespace: AWS/Lambda 180 | Period: 60 181 | Threshold: 250 182 | TreatMissingData: missing 183 | Unit: Milliseconds 184 | Type: AWS::CloudWatch::Alarm 185 | WebApiIndexLatencyP99Alarm: 186 | Properties: 187 | ComparisonOperator: GreaterThanOrEqualToThreshold 188 | Dimensions: 189 | - Name: FunctionName 190 | Value: 191 | Ref: WebApiIndex 192 | EvaluationPeriods: 3 193 | ExtendedStatistic: p99 194 | MetricName: Duration 195 | Namespace: AWS/Lambda 196 | Period: 60 197 | Threshold: 1000 198 | TreatMissingData: missing 199 | Unit: Milliseconds 200 | Type: AWS::CloudWatch::Alarm 201 | Transform: AWS::Serverless-2016-10-31 202 | -------------------------------------------------------------------------------- /CodeAfterExercise3/template.yaml: -------------------------------------------------------------------------------- 1 | AWSTemplateFormatVersion: '2010-09-09' 2 | Transform: AWS::Serverless-2016-10-31 3 | Description: > 4 | railsconf2019 5 | 6 | Sample SAM Template for railsconf2019 7 | 8 | # More info about Globals: https://github.com/awslabs/serverless-application-model/blob/master/docs/globals.rst 9 | Globals: 10 | Function: 11 | Timeout: 3 12 | 13 | 14 | Resources: 15 | WebApiIndex: 16 | Type: AWS::Serverless::Function 17 | Properties: 18 | CodeUri: app/ 19 | Handler: web_api.WebApi.index 20 | Runtime: ruby2.5 21 | Policies: 22 | - DynamoDBReadPolicy: 23 | TableName: !Ref PostsTable 24 | Environment: 25 | Variables: 26 | TABLE_NAME: !Ref PostsTable 27 | Events: 28 | ApiIndex: 29 | Type: Api 30 | Properties: 31 | Path: /posts 32 | Method: GET 33 | WebApiIndexErrorAlarm: 34 | Type: AWS::CloudWatch::Alarm 35 | Properties: 36 | Namespace: AWS/Lambda 37 | MetricName: Errors 38 | Dimensions: 39 | - Name: FunctionName 40 | Value: !Ref WebApiIndex 41 | ComparisonOperator: GreaterThanOrEqualToThreshold 42 | Statistic: Sum 43 | Threshold: 1 44 | EvaluationPeriods: 3 45 | Period: 60 46 | TreatMissingData: missing 47 | WebApiIndexLatencyP50Alarm: 48 | Type: AWS::CloudWatch::Alarm 49 | Properties: 50 | Namespace: AWS/Lambda 51 | MetricName: Duration 52 | Dimensions: 53 | - Name: FunctionName 54 | Value: !Ref WebApiIndex 55 | ComparisonOperator: GreaterThanOrEqualToThreshold 56 | ExtendedStatistic: p50 57 | Threshold: 250 58 | Unit: Milliseconds 59 | EvaluationPeriods: 3 60 | Period: 60 61 | TreatMissingData: missing 62 | WebApiIndexLatencyP99Alarm: 63 | Type: AWS::CloudWatch::Alarm 64 | Properties: 65 | Namespace: AWS/Lambda 66 | MetricName: Duration 67 | Dimensions: 68 | - Name: FunctionName 69 | Value: !Ref WebApiIndex 70 | ComparisonOperator: GreaterThanOrEqualToThreshold 71 | ExtendedStatistic: p99 72 | Threshold: 1000 73 | Unit: Milliseconds 74 | EvaluationPeriods: 3 75 | Period: 60 76 | TreatMissingData: missing 77 | WebApiGet: 78 | Type: AWS::Serverless::Function 79 | Properties: 80 | CodeUri: app/ 81 | Handler: web_api.WebApi.get 82 | Runtime: ruby2.5 83 | Policies: 84 | - DynamoDBReadPolicy: 85 | TableName: !Ref PostsTable 86 | Environment: 87 | Variables: 88 | TABLE_NAME: !Ref PostsTable 89 | Events: 90 | ApiGet: 91 | Type: Api 92 | Properties: 93 | Path: /posts/{uuid} 94 | Method: GET 95 | WebApiCreate: 96 | Type: AWS::Serverless::Function 97 | Properties: 98 | CodeUri: app/ 99 | Handler: web_api.WebApi.create 100 | Runtime: ruby2.5 101 | Policies: 102 | - DynamoDBCrudPolicy: 103 | TableName: !Ref PostsTable 104 | Environment: 105 | Variables: 106 | TABLE_NAME: !Ref PostsTable 107 | Events: 108 | ApiCreate: 109 | Type: Api 110 | Properties: 111 | Path: /posts 112 | Method: POST 113 | DeleteAllEventHandler: 114 | Type: AWS::Serverless::Function 115 | Properties: 116 | CodeUri: app/ 117 | Handler: event_handlers.EventHandlers.delete_all_posts 118 | Runtime: ruby2.5 119 | Policies: 120 | - DynamoDBCrudPolicy: 121 | TableName: !Ref PostsTable 122 | Environment: 123 | Variables: 124 | TABLE_NAME: !Ref PostsTable 125 | Events: 126 | QueueEvent: 127 | Type: SQS 128 | Properties: 129 | Queue: !GetAtt DeletePostQueue.Arn 130 | DeleteAllHandler: 131 | Type: AWS::Serverless::Function 132 | Properties: 133 | CodeUri: app/ 134 | Handler: web_api.WebApi.delete_all 135 | Runtime: ruby2.5 136 | Environment: 137 | Variables: 138 | SQS_QUEUE_URL: !Ref DeletePostQueue 139 | Policies: 140 | SQSSendMessagePolicy: 141 | QueueName: !GetAtt DeletePostQueue.QueueName 142 | Events: 143 | DeleteAllApi: 144 | Type: Api 145 | Properties: 146 | Path: /posts 147 | Method: DELETE 148 | DeletePostQueue: 149 | Type: AWS::SQS::Queue 150 | Properties: 151 | RedrivePolicy: 152 | deadLetterTargetArn: !GetAtt DeletePostDeadLetterQueue.Arn 153 | maxReceiveCount: 5 154 | DeletePostDeadLetterQueue: 155 | Type: AWS::SQS::Queue 156 | DeletePostDLQAlarm: 157 | Type: AWS::CloudWatch::Alarm 158 | Properties: 159 | Namespace: AWS/SQS 160 | MetricName: ApproximateNumberOfMessagesVisible 161 | Dimensions: 162 | - Name: QueueName 163 | Value: !GetAtt DeletePostDeadLetterQueue.QueueName 164 | ComparisonOperator: GreaterThanOrEqualToThreshold 165 | Statistic: Sum 166 | Threshold: 1 167 | EvaluationPeriods: 5 168 | Period: 60 169 | TreatMissingData: breaching 170 | PostsTable: 171 | Type: AWS::Serverless::SimpleTable 172 | Properties: 173 | PrimaryKey: 174 | Name: post_uuid 175 | Type: String 176 | 177 | Outputs: 178 | ApiEndpoint: 179 | Description: "API Gateway endpoint URL for Prod stage" 180 | Value: !Sub "https://${ServerlessRestApi}.execute-api.${AWS::Region}.amazonaws.com/Prod/" 181 | -------------------------------------------------------------------------------- /CodeAfterExercise3/tests/app/test_event_handlers.rb: -------------------------------------------------------------------------------- 1 | require 'minitest/autorun' 2 | require_relative '../../app/event_handlers' 3 | 4 | class EventHandlersTest < Minitest::Test 5 | def test_delete_all_posts 6 | input_event = { 7 | "Records" => [ 8 | { 9 | "messageId" => SecureRandom.uuid, 10 | "body" => "DELETE_ALL", 11 | "md5OfBody" => "319f263fe809cba0eb00f8977a972740" 12 | } 13 | ] 14 | } 15 | mock_post_a = Minitest::Mock.new 16 | mock_post_b = Minitest::Mock.new 17 | mock_post_c = Minitest::Mock.new 18 | post_list = [ 19 | mock_post_a, 20 | mock_post_b, 21 | mock_post_c 22 | ] 23 | mock_post_a.expect(:delete!, nil, []) 24 | mock_post_b.expect(:delete!, nil, []) 25 | mock_post_c.expect(:delete!, nil, []) 26 | Posts.stub(:scan, post_list) do 27 | EventHandlers.delete_all_posts(event: input_event, context: nil) 28 | end 29 | mock_post_a.verify 30 | mock_post_b.verify 31 | mock_post_c.verify 32 | end 33 | 34 | def test_delete_all_posts_bad_event 35 | input_event = { 36 | "Records" => [ 37 | { 38 | "messageId" => SecureRandom.uuid, 39 | "body" => "BAD_MESSAGE", 40 | "md5OfBody" => "6af3db524c14f32b6f183d51c8d04e8a" 41 | } 42 | ] 43 | } 44 | assert_raises(StandardError) { 45 | EventHandlers.delete_all_posts(event: input_event, context: nil) 46 | } 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /CodeAfterExercise3/tests/app/test_web_api.rb: -------------------------------------------------------------------------------- 1 | require 'minitest/autorun' 2 | require 'json' 3 | require_relative '../../app/web_api' 4 | 5 | class WebApiTest < Minitest::Test 6 | def test_index 7 | first_post_time = Time.at(1500000000).utc 8 | second_post_time = Time.at(1550000000).utc 9 | post_list = [ 10 | Posts.new( 11 | post_uuid: "a1", 12 | title: "First Post", 13 | body: "Hello, world!", 14 | created_at: first_post_time 15 | ), 16 | Posts.new( 17 | post_uuid: "b2", 18 | title: "Second Post", 19 | body: "Another post.", 20 | created_at: second_post_time 21 | ) 22 | ] 23 | record_collection = Minitest::Mock.new # mocking an itemcollection 24 | record_collection.expect(:page, post_list, []) 25 | expected_body = { 26 | posts: [ 27 | { 28 | post_uuid: "a1", 29 | title: "First Post", 30 | body: "Hello, world!", 31 | created_at: first_post_time 32 | }, 33 | { 34 | post_uuid: "b2", 35 | title: "Second Post", 36 | body: "Another post.", 37 | created_at: second_post_time 38 | } 39 | ] 40 | }.to_json 41 | Posts.stub(:scan, record_collection) do 42 | actual = WebApi.index(event: {}, context: nil) 43 | assert_equal(expected_body, actual[:body]) 44 | assert_equal(200, actual[:statusCode]) 45 | end 46 | record_collection.verify 47 | end 48 | 49 | def test_get 50 | post_time = Time.at(1500000000).utc 51 | post = Posts.new( 52 | post_uuid: "a1", 53 | title: "First Post", 54 | body: "Hello, world!", 55 | created_at: post_time 56 | ) 57 | expected = { 58 | statusCode: 200, 59 | body: { 60 | post: post.to_h 61 | }.to_json 62 | } 63 | Posts.stub(:find, post) do 64 | actual = WebApi.get( 65 | event: { 66 | "pathParameters" => { 67 | "uuid" => "a1" 68 | } 69 | }, 70 | context: nil 71 | ) 72 | assert_equal(expected, actual) 73 | end 74 | Posts.stub(:find, nil) do 75 | actual = WebApi.get( 76 | event: { 77 | "pathParameters" => { 78 | "uuid" => "a1" 79 | } 80 | }, 81 | context: nil 82 | ) 83 | assert_equal(404, actual[:statusCode]) 84 | end 85 | end 86 | 87 | def test_create 88 | input_event = { 89 | "body" => { 90 | title: "New Post", 91 | body: "Content!" 92 | }.to_json 93 | } 94 | mock = Minitest::Mock.new 95 | mock.expect(:save, true) 96 | mock.expect(:to_h, {}) # we don't check the return value in this test 97 | Posts.stub(:new, mock) do 98 | SecureRandom.stub(:uuid, "abc123") do 99 | Time.stub(:now, Time.at(1500000000)) do 100 | WebApi.create(event: input_event, context: nil) 101 | end 102 | end 103 | end 104 | mock.verify 105 | end 106 | end 107 | -------------------------------------------------------------------------------- /CodeAfterExercise4/.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.gitignore.io/api/osx,linux,python,windows,pycharm,visualstudiocode 3 | 4 | ### Linux ### 5 | *~ 6 | 7 | # temporary files which can be created if a process still has a handle open of a deleted file 8 | .fuse_hidden* 9 | 10 | # KDE directory preferences 11 | .directory 12 | 13 | # Linux trash folder which might appear on any partition or disk 14 | .Trash-* 15 | 16 | # .nfs files are created when an open file is removed but is still being accessed 17 | .nfs* 18 | 19 | ### OSX ### 20 | *.DS_Store 21 | .AppleDouble 22 | .LSOverride 23 | 24 | # Icon must end with two \r 25 | Icon 26 | 27 | # Thumbnails 28 | ._* 29 | 30 | # Files that might appear in the root of a volume 31 | .DocumentRevisions-V100 32 | .fseventsd 33 | .Spotlight-V100 34 | .TemporaryItems 35 | .Trashes 36 | .VolumeIcon.icns 37 | .com.apple.timemachine.donotpresent 38 | 39 | # Directories potentially created on remote AFP share 40 | .AppleDB 41 | .AppleDesktop 42 | Network Trash Folder 43 | Temporary Items 44 | .apdisk 45 | 46 | ### PyCharm ### 47 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm 48 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 49 | 50 | # User-specific stuff: 51 | .idea/**/workspace.xml 52 | .idea/**/tasks.xml 53 | .idea/dictionaries 54 | 55 | # Sensitive or high-churn files: 56 | .idea/**/dataSources/ 57 | .idea/**/dataSources.ids 58 | .idea/**/dataSources.xml 59 | .idea/**/dataSources.local.xml 60 | .idea/**/sqlDataSources.xml 61 | .idea/**/dynamic.xml 62 | .idea/**/uiDesigner.xml 63 | 64 | # Gradle: 65 | .idea/**/gradle.xml 66 | .idea/**/libraries 67 | 68 | # CMake 69 | cmake-build-debug/ 70 | 71 | # Mongo Explorer plugin: 72 | .idea/**/mongoSettings.xml 73 | 74 | ## File-based project format: 75 | *.iws 76 | 77 | ## Plugin-specific files: 78 | 79 | # IntelliJ 80 | /out/ 81 | 82 | # mpeltonen/sbt-idea plugin 83 | .idea_modules/ 84 | 85 | # JIRA plugin 86 | atlassian-ide-plugin.xml 87 | 88 | # Cursive Clojure plugin 89 | .idea/replstate.xml 90 | 91 | # Ruby plugin and RubyMine 92 | /.rakeTasks 93 | 94 | # Crashlytics plugin (for Android Studio and IntelliJ) 95 | com_crashlytics_export_strings.xml 96 | crashlytics.properties 97 | crashlytics-build.properties 98 | fabric.properties 99 | 100 | ### PyCharm Patch ### 101 | # Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 102 | 103 | # *.iml 104 | # modules.xml 105 | # .idea/misc.xml 106 | # *.ipr 107 | 108 | # Sonarlint plugin 109 | .idea/sonarlint 110 | 111 | ### Python ### 112 | # Byte-compiled / optimized / DLL files 113 | __pycache__/ 114 | *.py[cod] 115 | *$py.class 116 | 117 | # C extensions 118 | *.so 119 | 120 | # Distribution / packaging 121 | .Python 122 | build/ 123 | develop-eggs/ 124 | dist/ 125 | downloads/ 126 | eggs/ 127 | .eggs/ 128 | lib/ 129 | lib64/ 130 | parts/ 131 | sdist/ 132 | var/ 133 | wheels/ 134 | *.egg-info/ 135 | .installed.cfg 136 | *.egg 137 | 138 | # PyInstaller 139 | # Usually these files are written by a python script from a template 140 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 141 | *.manifest 142 | *.spec 143 | 144 | # Installer logs 145 | pip-log.txt 146 | pip-delete-this-directory.txt 147 | 148 | # Unit test / coverage reports 149 | htmlcov/ 150 | .tox/ 151 | .coverage 152 | .coverage.* 153 | .cache 154 | .pytest_cache/ 155 | nosetests.xml 156 | coverage.xml 157 | *.cover 158 | .hypothesis/ 159 | 160 | # Translations 161 | *.mo 162 | *.pot 163 | 164 | # Flask stuff: 165 | instance/ 166 | .webassets-cache 167 | 168 | # Scrapy stuff: 169 | .scrapy 170 | 171 | # Sphinx documentation 172 | docs/_build/ 173 | 174 | # PyBuilder 175 | target/ 176 | 177 | # Jupyter Notebook 178 | .ipynb_checkpoints 179 | 180 | # pyenv 181 | .python-version 182 | 183 | # celery beat schedule file 184 | celerybeat-schedule.* 185 | 186 | # SageMath parsed files 187 | *.sage.py 188 | 189 | # Environments 190 | .env 191 | .venv 192 | env/ 193 | venv/ 194 | ENV/ 195 | env.bak/ 196 | venv.bak/ 197 | 198 | # Spyder project settings 199 | .spyderproject 200 | .spyproject 201 | 202 | # Rope project settings 203 | .ropeproject 204 | 205 | # mkdocs documentation 206 | /site 207 | 208 | # mypy 209 | .mypy_cache/ 210 | 211 | ### VisualStudioCode ### 212 | .vscode/* 213 | !.vscode/settings.json 214 | !.vscode/tasks.json 215 | !.vscode/launch.json 216 | !.vscode/extensions.json 217 | .history 218 | 219 | ### Windows ### 220 | # Windows thumbnail cache files 221 | Thumbs.db 222 | ehthumbs.db 223 | ehthumbs_vista.db 224 | 225 | # Folder config file 226 | Desktop.ini 227 | 228 | # Recycle Bin used on file shares 229 | $RECYCLE.BIN/ 230 | 231 | # Windows Installer files 232 | *.cab 233 | *.msi 234 | *.msm 235 | *.msp 236 | 237 | # Windows shortcuts 238 | *.lnk 239 | 240 | # Build folder 241 | 242 | */build/* 243 | 244 | # End of https://www.gitignore.io/api/osx,linux,python,windows,pycharm,visualstudiocode 245 | 246 | # Don't put ZIP files into source control 247 | *.zip -------------------------------------------------------------------------------- /CodeAfterExercise4/Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | # Load app Gemfile dependencies 4 | eval(IO.read("app/Gemfile"), binding) 5 | 6 | group :test do 7 | gem 'minitest', '~> 5.11' 8 | end 9 | -------------------------------------------------------------------------------- /CodeAfterExercise4/app/Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gem 'aws-record', '~> 2' 4 | -------------------------------------------------------------------------------- /CodeAfterExercise4/app/event_handlers.rb: -------------------------------------------------------------------------------- 1 | require_relative 'posts' 2 | require 'json' 3 | 4 | class EventHandlers 5 | class << self 6 | def delete_all_posts(event:,context:) 7 | event["Records"].each do |record| 8 | if record["body"] == "DELETE_ALL" 9 | count = 0 10 | begin 11 | Posts.scan.each do |post| 12 | post.delete! 13 | count += 1 14 | end 15 | puts "[INFO] Deleted #{count} posts." 16 | rescue Aws::DynamoDB::Errors => e 17 | puts "[ERROR] Raised #{e.class} after deleting #{count} entries." 18 | raise(e) 19 | end 20 | else 21 | puts "[ERROR] Unsupported queue event: #{record.to_json}" 22 | raise StandardError.new( 23 | "Unsupported queue command: #{record["body"]}" 24 | ) 25 | end 26 | end 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /CodeAfterExercise4/app/posts.rb: -------------------------------------------------------------------------------- 1 | require 'aws-record' 2 | 3 | class Posts 4 | include Aws::Record 5 | set_table_name(ENV["TABLE_NAME"]) 6 | string_attr :post_uuid, hash_key: true 7 | string_attr :title 8 | string_attr :body 9 | epoch_time_attr :created_at 10 | end 11 | -------------------------------------------------------------------------------- /CodeAfterExercise4/app/web_api.rb: -------------------------------------------------------------------------------- 1 | require_relative 'posts' 2 | 3 | class WebApi 4 | class << self 5 | def index(event:,context:) 6 | posts = Posts.scan(limit: 25).page.map { |p| p.to_h } 7 | return { 8 | statusCode: 200, 9 | body: { posts: posts }.to_json 10 | } 11 | end 12 | 13 | def get(event:,context:) 14 | post_id = event["pathParameters"]["uuid"] 15 | post = Posts.find(post_uuid: post_id) 16 | if post 17 | return { 18 | statusCode: 200, 19 | body: { post: post.to_h }.to_json 20 | } 21 | else 22 | return { 23 | statusCode: 404, 24 | body: { error: "Post #{post_id} not found!" }.to_json 25 | } 26 | end 27 | end 28 | 29 | def create(event:,context:) 30 | params = _create_params(event["body"]) 31 | params[:post_uuid] = SecureRandom.uuid 32 | params[:created_at] = Time.now 33 | post = Posts.new(params) 34 | if post.save 35 | return { 36 | statusCode: 200, 37 | body: { post: post.to_h }.to_json 38 | } 39 | else 40 | return { 41 | statusCode: 500, 42 | body: { error: "Failed to create new post." } 43 | } 44 | end 45 | end 46 | 47 | def delete_all(event:,context:) 48 | _sqs_client.send_message( 49 | queue_url: _sqs_queue_url, 50 | message_body: "DELETE_ALL" 51 | ) 52 | return { 53 | statusCode: 204 54 | } 55 | end 56 | 57 | private 58 | def _create_params(body_input) 59 | ret = {} 60 | json = JSON.parse(body_input, symbolize_names: true) 61 | ret[:title] = json[:title] 62 | ret[:body] = json[:body] 63 | ret 64 | end 65 | 66 | def _sqs_client 67 | require 'aws-sdk-sqs' 68 | @@sqs_client ||= Aws::SQS::Client.new 69 | @@sqs_client 70 | end 71 | 72 | def _sqs_queue_url 73 | ENV["SQS_QUEUE_URL"] 74 | end 75 | end 76 | end 77 | -------------------------------------------------------------------------------- /CodeAfterExercise4/buildspec.yml: -------------------------------------------------------------------------------- 1 | version: 0.2 2 | 3 | phases: 4 | build: 5 | commands: 6 | - bundle install 7 | - ruby tests/app/test_web_api.rb 8 | - ruby tests/app/test_event_handlers.rb 9 | - cd app 10 | - bundle install --gemfile Gemfile 11 | - bundle install --gemfile Gemfile --deployment 12 | - cd .. 13 | - aws cloudformation package --template-file template.yaml --output-template-file packaged.yaml --s3-bucket $SOURCE_BUCKET_NAME 14 | 15 | artifacts: 16 | type: zip 17 | files: 18 | - packaged.yaml 19 | -------------------------------------------------------------------------------- /CodeAfterExercise4/pipeline/pipeline-template.yml: -------------------------------------------------------------------------------- 1 | Parameters: 2 | AppStackName: 3 | Type: String 4 | Description: "The name of the CloudFormation stack you have deployed your application to." 5 | Default: railsconf2019 6 | PipelineName: 7 | Type: String 8 | Description: "Name of the CodePipeline to create." 9 | SourceBucketName: 10 | Type: String 11 | Description: "S3 bucket name to use for the source code." 12 | SourceZipKey: 13 | Type: String 14 | Description: "S3 key in the Source Bucket where source code is stored." 15 | Default: railsconf-source.zip 16 | Resources: 17 | SourceCodeBucket: 18 | Type: AWS::S3::Bucket 19 | Properties: 20 | BucketName: !Ref SourceBucketName 21 | VersioningConfiguration: 22 | Status: Enabled 23 | DeletionPolicy: Retain 24 | LambdaPipelineArtifactsBucket: 25 | Type: AWS::S3::Bucket 26 | DeletionPolicy: Retain 27 | LambdaPipelineRole: 28 | Type: AWS::IAM::Role 29 | Properties: 30 | AssumeRolePolicyDocument: 31 | Statement: 32 | - Action: sts:AssumeRole 33 | Effect: Allow 34 | Principal: 35 | Service: codepipeline.amazonaws.com 36 | Version: "2012-10-17" 37 | LambdaPipelineRoleDefaultPolicy: 38 | Type: AWS::IAM::Policy 39 | Properties: 40 | PolicyDocument: 41 | Statement: 42 | - Action: 43 | - s3:GetObject* 44 | - s3:GetBucket* 45 | - s3:List* 46 | - s3:DeleteObject* 47 | - s3:PutObject* 48 | - s3:Abort* 49 | Effect: Allow 50 | Resource: 51 | - Fn::GetAtt: 52 | - LambdaPipelineArtifactsBucket 53 | - Arn 54 | - Fn::Join: 55 | - "" 56 | - - Fn::GetAtt: 57 | - LambdaPipelineArtifactsBucket 58 | - Arn 59 | - /* 60 | - Action: 61 | - s3:GetObject* 62 | - s3:GetBucket* 63 | - s3:List* 64 | Effect: Allow 65 | Resource: 66 | - Fn::GetAtt: 67 | - SourceCodeBucket 68 | - Arn 69 | - Fn::Join: 70 | - "" 71 | - - Fn::GetAtt: 72 | - SourceCodeBucket 73 | - Arn 74 | - /* 75 | - Action: 76 | - codebuild:BatchGetBuilds 77 | - codebuild:StartBuild 78 | - codebuild:StopBuild 79 | Effect: Allow 80 | Resource: 81 | Fn::GetAtt: 82 | - BuildProject 83 | - Arn 84 | - Action: iam:PassRole 85 | Effect: Allow 86 | Resource: 87 | Fn::GetAtt: 88 | - CloudFormationDeploymentRole 89 | - Arn 90 | - Action: 91 | - cloudformation:CreateStack 92 | - cloudformation:DescribeStack* 93 | - cloudformation:GetStackPolicy 94 | - cloudformation:GetTemplate* 95 | - cloudformation:SetStackPolicy 96 | - cloudformation:UpdateStack 97 | - cloudformation:ValidateTemplate 98 | Effect: Allow 99 | Resource: 100 | Fn::Join: 101 | - "" 102 | - - "arn:" 103 | - Ref: AWS::Partition 104 | - ":cloudformation:" 105 | - Ref: AWS::Region 106 | - ":" 107 | - Ref: AWS::AccountId 108 | - ":stack/" 109 | - Ref: AppStackName 110 | - "/*" 111 | Version: "2012-10-17" 112 | PolicyName: LambdaPipelineRoleDefaultPolicy 113 | Roles: 114 | - Ref: LambdaPipelineRole 115 | LambdaPipeline: 116 | Type: AWS::CodePipeline::Pipeline 117 | Properties: 118 | RoleArn: 119 | Fn::GetAtt: 120 | - LambdaPipelineRole 121 | - Arn 122 | Stages: 123 | - Actions: 124 | - ActionTypeId: 125 | Category: Source 126 | Owner: AWS 127 | Provider: S3 128 | Version: "1" 129 | Configuration: 130 | S3Bucket: 131 | Ref: SourceCodeBucket 132 | S3ObjectKey: !Ref SourceZipKey 133 | PollForSourceChanges: true 134 | InputArtifacts: 135 | [] 136 | Name: S3Source 137 | OutputArtifacts: 138 | - Name: Artifact_InfrastructureStackS3Source 139 | RunOrder: 1 140 | Name: Source 141 | - Actions: 142 | - ActionTypeId: 143 | Category: Build 144 | Owner: AWS 145 | Provider: CodeBuild 146 | Version: "1" 147 | Configuration: 148 | ProjectName: 149 | Ref: BuildProject 150 | InputArtifacts: 151 | - Name: Artifact_InfrastructureStackS3Source 152 | Name: BuildAction 153 | OutputArtifacts: 154 | - Name: Artifact_InfrastructureStackBuildAction 155 | RunOrder: 1 156 | Name: Build 157 | - Actions: 158 | - ActionTypeId: 159 | Category: Deploy 160 | Owner: AWS 161 | Provider: CloudFormation 162 | Version: "1" 163 | Configuration: 164 | StackName: !Ref AppStackName 165 | ActionMode: CREATE_UPDATE 166 | TemplatePath: Artifact_InfrastructureStackBuildAction::packaged.yaml 167 | Capabilities: CAPABILITY_IAM,CAPABILITY_AUTO_EXPAND 168 | RoleArn: 169 | Fn::GetAtt: 170 | - CloudFormationDeploymentRole 171 | - Arn 172 | InputArtifacts: 173 | - Name: Artifact_InfrastructureStackBuildAction 174 | Name: CloudFrontDeployment 175 | OutputArtifacts: 176 | [] 177 | RunOrder: 1 178 | Name: Deploy 179 | ArtifactStore: 180 | Location: 181 | Ref: LambdaPipelineArtifactsBucket 182 | Type: S3 183 | Name: !Ref PipelineName 184 | DependsOn: 185 | - LambdaPipelineRole 186 | - LambdaPipelineRoleDefaultPolicy 187 | BuildProjectRole: 188 | Type: AWS::IAM::Role 189 | Properties: 190 | AssumeRolePolicyDocument: 191 | Statement: 192 | - Action: sts:AssumeRole 193 | Effect: Allow 194 | Principal: 195 | Service: codebuild.amazonaws.com 196 | Version: "2012-10-17" 197 | BuildProjectRoleDefaultPolicy: 198 | Type: AWS::IAM::Policy 199 | Properties: 200 | PolicyDocument: 201 | Statement: 202 | - Action: 203 | - logs:CreateLogGroup 204 | - logs:CreateLogStream 205 | - logs:PutLogEvents 206 | Effect: Allow 207 | Resource: 208 | - Fn::Join: 209 | - "" 210 | - - "arn:" 211 | - Ref: AWS::Partition 212 | - ":logs:" 213 | - Ref: AWS::Region 214 | - ":" 215 | - Ref: AWS::AccountId 216 | - :log-group:/aws/codebuild/ 217 | - Ref: BuildProject 218 | - Fn::Join: 219 | - "" 220 | - - "arn:" 221 | - Ref: AWS::Partition 222 | - ":logs:" 223 | - Ref: AWS::Region 224 | - ":" 225 | - Ref: AWS::AccountId 226 | - :log-group:/aws/codebuild/ 227 | - Ref: BuildProject 228 | - :* 229 | - Action: 230 | - s3:GetObject* 231 | - s3:GetBucket* 232 | - s3:List* 233 | - s3:DeleteObject* 234 | - s3:PutObject* 235 | - s3:Abort* 236 | Effect: Allow 237 | Resource: 238 | - Fn::GetAtt: 239 | - LambdaPipelineArtifactsBucket 240 | - Arn 241 | - Fn::Join: 242 | - "" 243 | - - Fn::GetAtt: 244 | - LambdaPipelineArtifactsBucket 245 | - Arn 246 | - /* 247 | - Action: 248 | - s3:GetObject* 249 | - s3:GetBucket* 250 | - s3:List* 251 | - s3:DeleteObject* 252 | - s3:PutObject* 253 | - s3:Abort* 254 | Effect: Allow 255 | Resource: 256 | - Fn::GetAtt: 257 | - SourceCodeBucket 258 | - Arn 259 | - Fn::Join: 260 | - "" 261 | - - Fn::GetAtt: 262 | - SourceCodeBucket 263 | - Arn 264 | - /* 265 | Version: "2012-10-17" 266 | PolicyName: BuildProjectRoleDefaultPolicy 267 | Roles: 268 | - Ref: BuildProjectRole 269 | BuildProject: 270 | Type: AWS::CodeBuild::Project 271 | Properties: 272 | Artifacts: 273 | Type: CODEPIPELINE 274 | Environment: 275 | ComputeType: BUILD_GENERAL1_SMALL 276 | Image: aws/codebuild/ruby:2.5.3 277 | PrivilegedMode: false 278 | Type: LINUX_CONTAINER 279 | EnvironmentVariables: 280 | - Name: SOURCE_BUCKET_NAME 281 | Value: !Ref SourceBucketName 282 | ServiceRole: 283 | Fn::GetAtt: 284 | - BuildProjectRole 285 | - Arn 286 | Source: 287 | BuildSpec: buildspec.yml 288 | Type: CODEPIPELINE 289 | CloudFormationDeploymentRole: 290 | Type: AWS::IAM::Role 291 | Properties: 292 | AssumeRolePolicyDocument: 293 | Statement: 294 | - Action: sts:AssumeRole 295 | Effect: Allow 296 | Principal: 297 | Service: cloudformation.amazonaws.com 298 | Version: "2012-10-17" 299 | CloudFormationDeploymentRoleDefaultPolicy: 300 | Type: AWS::IAM::Policy 301 | Properties: 302 | PolicyDocument: 303 | Statement: 304 | - Action: "*" 305 | Effect: Allow 306 | Resource: "*" 307 | Version: "2012-10-17" 308 | PolicyName: CloudFormationDeploymentRoleDefaultPolicy 309 | Roles: 310 | - Ref: CloudFormationDeploymentRole 311 | -------------------------------------------------------------------------------- /CodeAfterExercise4/template.yaml: -------------------------------------------------------------------------------- 1 | AWSTemplateFormatVersion: '2010-09-09' 2 | Transform: AWS::Serverless-2016-10-31 3 | Description: > 4 | railsconf2019 5 | 6 | Sample SAM Template for railsconf2019 7 | 8 | # More info about Globals: https://github.com/awslabs/serverless-application-model/blob/master/docs/globals.rst 9 | Globals: 10 | Function: 11 | Timeout: 3 12 | 13 | 14 | Resources: 15 | WebApiIndex: 16 | Type: AWS::Serverless::Function 17 | Properties: 18 | CodeUri: app/ 19 | Handler: web_api.WebApi.index 20 | Runtime: ruby2.5 21 | Policies: 22 | - DynamoDBReadPolicy: 23 | TableName: !Ref PostsTable 24 | Environment: 25 | Variables: 26 | TABLE_NAME: !Ref PostsTable 27 | Events: 28 | ApiIndex: 29 | Type: Api 30 | Properties: 31 | Path: /posts 32 | Method: GET 33 | WebApiIndexErrorAlarm: 34 | Type: AWS::CloudWatch::Alarm 35 | Properties: 36 | Namespace: AWS/Lambda 37 | MetricName: Errors 38 | Dimensions: 39 | - Name: FunctionName 40 | Value: !Ref WebApiIndex 41 | ComparisonOperator: GreaterThanOrEqualToThreshold 42 | Statistic: Sum 43 | Threshold: 1 44 | EvaluationPeriods: 3 45 | Period: 60 46 | TreatMissingData: missing 47 | WebApiIndexLatencyP50Alarm: 48 | Type: AWS::CloudWatch::Alarm 49 | Properties: 50 | Namespace: AWS/Lambda 51 | MetricName: Duration 52 | Dimensions: 53 | - Name: FunctionName 54 | Value: !Ref WebApiIndex 55 | ComparisonOperator: GreaterThanOrEqualToThreshold 56 | ExtendedStatistic: p50 57 | Threshold: 250 58 | Unit: Milliseconds 59 | EvaluationPeriods: 3 60 | Period: 60 61 | TreatMissingData: missing 62 | WebApiIndexLatencyP99Alarm: 63 | Type: AWS::CloudWatch::Alarm 64 | Properties: 65 | Namespace: AWS/Lambda 66 | MetricName: Duration 67 | Dimensions: 68 | - Name: FunctionName 69 | Value: !Ref WebApiIndex 70 | ComparisonOperator: GreaterThanOrEqualToThreshold 71 | ExtendedStatistic: p99 72 | Threshold: 1000 73 | Unit: Milliseconds 74 | EvaluationPeriods: 3 75 | Period: 60 76 | TreatMissingData: missing 77 | WebApiGet: 78 | Type: AWS::Serverless::Function 79 | Properties: 80 | CodeUri: app/ 81 | Handler: web_api.WebApi.get 82 | Runtime: ruby2.5 83 | Policies: 84 | - DynamoDBReadPolicy: 85 | TableName: !Ref PostsTable 86 | Environment: 87 | Variables: 88 | TABLE_NAME: !Ref PostsTable 89 | Events: 90 | ApiGet: 91 | Type: Api 92 | Properties: 93 | Path: /posts/{uuid} 94 | Method: GET 95 | WebApiCreate: 96 | Type: AWS::Serverless::Function 97 | Properties: 98 | CodeUri: app/ 99 | Handler: web_api.WebApi.create 100 | Runtime: ruby2.5 101 | Policies: 102 | - DynamoDBCrudPolicy: 103 | TableName: !Ref PostsTable 104 | Environment: 105 | Variables: 106 | TABLE_NAME: !Ref PostsTable 107 | Events: 108 | ApiCreate: 109 | Type: Api 110 | Properties: 111 | Path: /posts 112 | Method: POST 113 | DeleteAllEventHandler: 114 | Type: AWS::Serverless::Function 115 | Properties: 116 | CodeUri: app/ 117 | Handler: event_handlers.EventHandlers.delete_all_posts 118 | Runtime: ruby2.5 119 | Policies: 120 | - DynamoDBCrudPolicy: 121 | TableName: !Ref PostsTable 122 | Environment: 123 | Variables: 124 | TABLE_NAME: !Ref PostsTable 125 | Events: 126 | QueueEvent: 127 | Type: SQS 128 | Properties: 129 | Queue: !GetAtt DeletePostQueue.Arn 130 | DeleteAllHandler: 131 | Type: AWS::Serverless::Function 132 | Properties: 133 | CodeUri: app/ 134 | Handler: web_api.WebApi.delete_all 135 | Runtime: ruby2.5 136 | Environment: 137 | Variables: 138 | SQS_QUEUE_URL: !Ref DeletePostQueue 139 | Policies: 140 | SQSSendMessagePolicy: 141 | QueueName: !GetAtt DeletePostQueue.QueueName 142 | Events: 143 | DeleteAllApi: 144 | Type: Api 145 | Properties: 146 | Path: /posts 147 | Method: DELETE 148 | DeletePostQueue: 149 | Type: AWS::SQS::Queue 150 | Properties: 151 | RedrivePolicy: 152 | deadLetterTargetArn: !GetAtt DeletePostDeadLetterQueue.Arn 153 | maxReceiveCount: 5 154 | DeletePostDeadLetterQueue: 155 | Type: AWS::SQS::Queue 156 | DeletePostDLQAlarm: 157 | Type: AWS::CloudWatch::Alarm 158 | Properties: 159 | Namespace: AWS/SQS 160 | MetricName: ApproximateNumberOfMessagesVisible 161 | Dimensions: 162 | - Name: QueueName 163 | Value: !GetAtt DeletePostDeadLetterQueue.QueueName 164 | ComparisonOperator: GreaterThanOrEqualToThreshold 165 | Statistic: Sum 166 | Threshold: 1 167 | EvaluationPeriods: 5 168 | Period: 60 169 | TreatMissingData: breaching 170 | PostsTable: 171 | Type: AWS::Serverless::SimpleTable 172 | Properties: 173 | PrimaryKey: 174 | Name: post_uuid 175 | Type: String 176 | 177 | Outputs: 178 | ApiEndpoint: 179 | Description: "API Gateway endpoint URL for Prod stage" 180 | Value: !Sub "https://${ServerlessRestApi}.execute-api.${AWS::Region}.amazonaws.com/Prod/" 181 | -------------------------------------------------------------------------------- /CodeAfterExercise4/tests/app/test_event_handlers.rb: -------------------------------------------------------------------------------- 1 | require 'minitest/autorun' 2 | require_relative '../../app/event_handlers' 3 | 4 | class EventHandlersTest < Minitest::Test 5 | def test_delete_all_posts 6 | input_event = { 7 | "Records" => [ 8 | { 9 | "messageId" => SecureRandom.uuid, 10 | "body" => "DELETE_ALL", 11 | "md5OfBody" => "319f263fe809cba0eb00f8977a972740" 12 | } 13 | ] 14 | } 15 | mock_post_a = Minitest::Mock.new 16 | mock_post_b = Minitest::Mock.new 17 | mock_post_c = Minitest::Mock.new 18 | post_list = [ 19 | mock_post_a, 20 | mock_post_b, 21 | mock_post_c 22 | ] 23 | mock_post_a.expect(:delete!, nil, []) 24 | mock_post_b.expect(:delete!, nil, []) 25 | mock_post_c.expect(:delete!, nil, []) 26 | Posts.stub(:scan, post_list) do 27 | EventHandlers.delete_all_posts(event: input_event, context: nil) 28 | end 29 | mock_post_a.verify 30 | mock_post_b.verify 31 | mock_post_c.verify 32 | end 33 | 34 | def test_delete_all_posts_bad_event 35 | input_event = { 36 | "Records" => [ 37 | { 38 | "messageId" => SecureRandom.uuid, 39 | "body" => "BAD_MESSAGE", 40 | "md5OfBody" => "6af3db524c14f32b6f183d51c8d04e8a" 41 | } 42 | ] 43 | } 44 | assert_raises(StandardError) { 45 | EventHandlers.delete_all_posts(event: input_event, context: nil) 46 | } 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /CodeAfterExercise4/tests/app/test_web_api.rb: -------------------------------------------------------------------------------- 1 | require 'minitest/autorun' 2 | require 'json' 3 | require_relative '../../app/web_api' 4 | 5 | class WebApiTest < Minitest::Test 6 | def test_index 7 | first_post_time = Time.at(1500000000).utc 8 | second_post_time = Time.at(1550000000).utc 9 | post_list = [ 10 | Posts.new( 11 | post_uuid: "a1", 12 | title: "First Post", 13 | body: "Hello, world!", 14 | created_at: first_post_time 15 | ), 16 | Posts.new( 17 | post_uuid: "b2", 18 | title: "Second Post", 19 | body: "Another post.", 20 | created_at: second_post_time 21 | ) 22 | ] 23 | record_collection = Minitest::Mock.new # mocking an itemcollection 24 | record_collection.expect(:page, post_list, []) 25 | expected_body = { 26 | posts: [ 27 | { 28 | post_uuid: "a1", 29 | title: "First Post", 30 | body: "Hello, world!", 31 | created_at: first_post_time 32 | }, 33 | { 34 | post_uuid: "b2", 35 | title: "Second Post", 36 | body: "Another post.", 37 | created_at: second_post_time 38 | } 39 | ] 40 | }.to_json 41 | Posts.stub(:scan, record_collection) do 42 | actual = WebApi.index(event: {}, context: nil) 43 | assert_equal(expected_body, actual[:body]) 44 | assert_equal(200, actual[:statusCode]) 45 | end 46 | record_collection.verify 47 | end 48 | 49 | def test_get 50 | post_time = Time.at(1500000000).utc 51 | post = Posts.new( 52 | post_uuid: "a1", 53 | title: "First Post", 54 | body: "Hello, world!", 55 | created_at: post_time 56 | ) 57 | expected = { 58 | statusCode: 200, 59 | body: { 60 | post: post.to_h 61 | }.to_json 62 | } 63 | Posts.stub(:find, post) do 64 | actual = WebApi.get( 65 | event: { 66 | "pathParameters" => { 67 | "uuid" => "a1" 68 | } 69 | }, 70 | context: nil 71 | ) 72 | assert_equal(expected, actual) 73 | end 74 | Posts.stub(:find, nil) do 75 | actual = WebApi.get( 76 | event: { 77 | "pathParameters" => { 78 | "uuid" => "a1" 79 | } 80 | }, 81 | context: nil 82 | ) 83 | assert_equal(404, actual[:statusCode]) 84 | end 85 | end 86 | 87 | def test_create 88 | input_event = { 89 | "body" => { 90 | title: "New Post", 91 | body: "Content!" 92 | }.to_json 93 | } 94 | mock = Minitest::Mock.new 95 | mock.expect(:save, true) 96 | mock.expect(:to_h, {}) # we don't check the return value in this test 97 | Posts.stub(:new, mock) do 98 | SecureRandom.stub(:uuid, "abc123") do 99 | Time.stub(:now, Time.at(1500000000)) do 100 | WebApi.create(event: input_event, context: nil) 101 | end 102 | end 103 | end 104 | mock.verify 105 | end 106 | end 107 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the "Software"), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 7 | the Software, and to permit persons to whom the Software is furnished to do so. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 10 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 11 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 12 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 13 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 14 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 15 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## AWS Lambda - RailsConf 2 | 3 | Instructions and examples for the workshop "Going Serverless with Ruby on AWS Lambda" at RailsConf 2019. 4 | 5 | ## License Summary 6 | 7 | This sample code is made available under the MIT-0 license. See the LICENSE file. 8 | -------------------------------------------------------------------------------- /StartingPointCode/.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.gitignore.io/api/osx,linux,python,windows,pycharm,visualstudiocode 3 | 4 | ### Linux ### 5 | *~ 6 | 7 | # temporary files which can be created if a process still has a handle open of a deleted file 8 | .fuse_hidden* 9 | 10 | # KDE directory preferences 11 | .directory 12 | 13 | # Linux trash folder which might appear on any partition or disk 14 | .Trash-* 15 | 16 | # .nfs files are created when an open file is removed but is still being accessed 17 | .nfs* 18 | 19 | ### OSX ### 20 | *.DS_Store 21 | .AppleDouble 22 | .LSOverride 23 | 24 | # Icon must end with two \r 25 | Icon 26 | 27 | # Thumbnails 28 | ._* 29 | 30 | # Files that might appear in the root of a volume 31 | .DocumentRevisions-V100 32 | .fseventsd 33 | .Spotlight-V100 34 | .TemporaryItems 35 | .Trashes 36 | .VolumeIcon.icns 37 | .com.apple.timemachine.donotpresent 38 | 39 | # Directories potentially created on remote AFP share 40 | .AppleDB 41 | .AppleDesktop 42 | Network Trash Folder 43 | Temporary Items 44 | .apdisk 45 | 46 | ### PyCharm ### 47 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm 48 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 49 | 50 | # User-specific stuff: 51 | .idea/**/workspace.xml 52 | .idea/**/tasks.xml 53 | .idea/dictionaries 54 | 55 | # Sensitive or high-churn files: 56 | .idea/**/dataSources/ 57 | .idea/**/dataSources.ids 58 | .idea/**/dataSources.xml 59 | .idea/**/dataSources.local.xml 60 | .idea/**/sqlDataSources.xml 61 | .idea/**/dynamic.xml 62 | .idea/**/uiDesigner.xml 63 | 64 | # Gradle: 65 | .idea/**/gradle.xml 66 | .idea/**/libraries 67 | 68 | # CMake 69 | cmake-build-debug/ 70 | 71 | # Mongo Explorer plugin: 72 | .idea/**/mongoSettings.xml 73 | 74 | ## File-based project format: 75 | *.iws 76 | 77 | ## Plugin-specific files: 78 | 79 | # IntelliJ 80 | /out/ 81 | 82 | # mpeltonen/sbt-idea plugin 83 | .idea_modules/ 84 | 85 | # JIRA plugin 86 | atlassian-ide-plugin.xml 87 | 88 | # Cursive Clojure plugin 89 | .idea/replstate.xml 90 | 91 | # Ruby plugin and RubyMine 92 | /.rakeTasks 93 | 94 | # Crashlytics plugin (for Android Studio and IntelliJ) 95 | com_crashlytics_export_strings.xml 96 | crashlytics.properties 97 | crashlytics-build.properties 98 | fabric.properties 99 | 100 | ### PyCharm Patch ### 101 | # Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 102 | 103 | # *.iml 104 | # modules.xml 105 | # .idea/misc.xml 106 | # *.ipr 107 | 108 | # Sonarlint plugin 109 | .idea/sonarlint 110 | 111 | ### Python ### 112 | # Byte-compiled / optimized / DLL files 113 | __pycache__/ 114 | *.py[cod] 115 | *$py.class 116 | 117 | # C extensions 118 | *.so 119 | 120 | # Distribution / packaging 121 | .Python 122 | build/ 123 | develop-eggs/ 124 | dist/ 125 | downloads/ 126 | eggs/ 127 | .eggs/ 128 | lib/ 129 | lib64/ 130 | parts/ 131 | sdist/ 132 | var/ 133 | wheels/ 134 | *.egg-info/ 135 | .installed.cfg 136 | *.egg 137 | 138 | # PyInstaller 139 | # Usually these files are written by a python script from a template 140 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 141 | *.manifest 142 | *.spec 143 | 144 | # Installer logs 145 | pip-log.txt 146 | pip-delete-this-directory.txt 147 | 148 | # Unit test / coverage reports 149 | htmlcov/ 150 | .tox/ 151 | .coverage 152 | .coverage.* 153 | .cache 154 | .pytest_cache/ 155 | nosetests.xml 156 | coverage.xml 157 | *.cover 158 | .hypothesis/ 159 | 160 | # Translations 161 | *.mo 162 | *.pot 163 | 164 | # Flask stuff: 165 | instance/ 166 | .webassets-cache 167 | 168 | # Scrapy stuff: 169 | .scrapy 170 | 171 | # Sphinx documentation 172 | docs/_build/ 173 | 174 | # PyBuilder 175 | target/ 176 | 177 | # Jupyter Notebook 178 | .ipynb_checkpoints 179 | 180 | # pyenv 181 | .python-version 182 | 183 | # celery beat schedule file 184 | celerybeat-schedule.* 185 | 186 | # SageMath parsed files 187 | *.sage.py 188 | 189 | # Environments 190 | .env 191 | .venv 192 | env/ 193 | venv/ 194 | ENV/ 195 | env.bak/ 196 | venv.bak/ 197 | 198 | # Spyder project settings 199 | .spyderproject 200 | .spyproject 201 | 202 | # Rope project settings 203 | .ropeproject 204 | 205 | # mkdocs documentation 206 | /site 207 | 208 | # mypy 209 | .mypy_cache/ 210 | 211 | ### VisualStudioCode ### 212 | .vscode/* 213 | !.vscode/settings.json 214 | !.vscode/tasks.json 215 | !.vscode/launch.json 216 | !.vscode/extensions.json 217 | .history 218 | 219 | ### Windows ### 220 | # Windows thumbnail cache files 221 | Thumbs.db 222 | ehthumbs.db 223 | ehthumbs_vista.db 224 | 225 | # Folder config file 226 | Desktop.ini 227 | 228 | # Recycle Bin used on file shares 229 | $RECYCLE.BIN/ 230 | 231 | # Windows Installer files 232 | *.cab 233 | *.msi 234 | *.msm 235 | *.msp 236 | 237 | # Windows shortcuts 238 | *.lnk 239 | 240 | # Build folder 241 | 242 | */build/* 243 | 244 | # End of https://www.gitignore.io/api/osx,linux,python,windows,pycharm,visualstudiocode 245 | 246 | # Don't put ZIP files into source control 247 | *.zip -------------------------------------------------------------------------------- /StartingPointCode/Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | # Load app Gemfile dependencies 4 | eval(IO.read("app/Gemfile"), binding) 5 | 6 | group :test do 7 | gem 'minitest', '~> 5.11' 8 | end 9 | -------------------------------------------------------------------------------- /StartingPointCode/app/Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | -------------------------------------------------------------------------------- /StartingPointCode/template.yaml: -------------------------------------------------------------------------------- 1 | AWSTemplateFormatVersion: '2010-09-09' 2 | Transform: AWS::Serverless-2016-10-31 3 | Description: > 4 | railsconf2019 5 | 6 | Sample SAM Template for railsconf2019 7 | 8 | # More info about Globals: https://github.com/awslabs/serverless-application-model/blob/master/docs/globals.rst 9 | Globals: 10 | Function: 11 | Timeout: 3 12 | 13 | 14 | Resources: 15 | 16 | 17 | Outputs: 18 | ApiEndpoint: 19 | Description: "API Gateway endpoint URL for Prod stage" 20 | Value: !Sub "https://${ServerlessRestApi}.execute-api.${AWS::Region}.amazonaws.com/Prod/" 21 | -------------------------------------------------------------------------------- /WORKSHEET.md: -------------------------------------------------------------------------------- 1 | # Going Serverless with Ruby on AWS Lambda 2 | 3 | In this lab, we're going to develop and deploy both web and event-based functions to AWS Lambda, as well as introduce continuous deployment to our application, and then show how we can incorporate web frameworks. We'll also have an exercise to explore options for alarm configuration. 4 | 5 | ## Exercise -1: How To Complete This Workshop 6 | 7 | A Note: This advice applies primarily to the live workshop. If you're running this workshop on your own, opening a GitHub issue is the easiest way to get help. 8 | 9 | The exercises in this workshop are meant to be completed in order. Ideally, you've already completed Exercise 0 and installed the dependencies for this workshop. If not, I recommend starting right away - the introductory slides will be on video after the conference. 10 | 11 | At the live workshop, we'll be going around to help throughout, and occasionally demoing certain parts of the process live. If you fall behind, or if an instruction is confusing and you get stuck, I've included snapshots of what the code should look like after exercises 1, 2, 3, and even 4 (the optional/take-home exercise) are complete. Feel free to compare code, or copy one of those snapshots and resume from them. 12 | 13 | Ask questions early and often! We'll be demoing answers to common questions as they come up, but most of the time is intended for us to help you one-on-one! Take advantage. 14 | 15 | Some pointers to keep in mind: 16 | 17 | * Make sure you pay attention to `--region` settings, when used. One key point is that the region your source bucket is in, much match the region you deploy to. 18 | * Because the template file is YAML formatted, indentation matters. If you're getting a deployment error that doesn't make sense, there's a good chance your YAML file isn't indented properly somewhere. 19 | * Make extra sure that you're using Ruby 2.5 in your command line where you build dependencies. If your shell defaults to Ruby 2.6, you may end up with mysterious errors after deploying, because vendored dependency file paths are sensitive to the Ruby minor version in use. Ruby 2.5.5 is recommended, but any Ruby version in the 2.5.x family should work. 20 | 21 | ## Exercise 0: Setup 22 | 23 | ### Set Up Ruby 2.5 24 | 25 | When you build your AWS Lambda dependencies, you'll want to use containerized builds or to ensure you're using Ruby 2.5. I recommend you use `rbenv` or `rvm` to manage your versions of Ruby: 26 | 27 | * [rbenv Installation Instructions](https://github.com/rbenv/rbenv#installation) 28 | * [rvm Installation Instructions](https://rvm.io/rvm/install) 29 | 30 | It is important to make sure that you're using Ruby 2.5. Any minor version works, but the latest (2.5.5) is recommended as a best practice. 31 | 32 | ```shell 33 | # in rbenv 34 | rbenv install 2.5.5 # if not already installed 35 | export RBENV_VERSION=2.5.5 36 | 37 | # in rvm 38 | rvm install 2.5.5 # if not already installed 39 | rvm use 2.5.5 40 | 41 | # verification 42 | ruby -v 43 | ``` 44 | 45 | If you have set your version for your Ruby version manager but it doesn't match the output of `ruby -v`, then review your Ruby version manager setup instructions (you may have to refresh your shell environment, for example). 46 | 47 | ### Set Up AWS CLI 48 | 49 | If you do not have the AWS CLI installed on your system, I recommend you use the [bundled installer](https://docs.aws.amazon.com/cli/latest/userguide/install-bundle.html) for the easiest installation experience. I recommend using the latest version of the AWS CLI for this lab. 50 | 51 | ### Set Up AWS Shared Credential File 52 | 53 | You will need AWS credentials in order to perform your deployments via AWS CloudFormation, and your role used will need to have permissions to create IAM roles. If you do not have credentials configured on your development environment, you can follow this [guide in the AWS CLI developer guide](https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-files.html). 54 | 55 | Ensure that you place the credentials you're going to use in your "default" profile, or if you have your own profile setup that you're using the correct AWS_PROFILE environment variable setting. 56 | 57 | ### Set Up AWS SAM CLI & Docker 58 | 59 | The installation steps for AWS SAM CLI are documented in the [AWS SAM CLI developer guide](https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/serverless-sam-cli-install.html). Note also that Docker installation instructions are included in this guide. 60 | 61 | ### Create an S3 Bucket 62 | 63 | You will need to have an S3 bucket to use for storing your code artifacts when deploying your application. You may use an existing bucket (just make sure to note its region for later), or you can create one using the AWS CLI like so: 64 | 65 | ```shell 66 | # substitute a unique name for your bucket 67 | export RAILSCONF_SOURCE_BUCKET=my-railsconf-source-bucket 68 | aws s3api create-bucket --bucket $RAILSCONF_SOURCE_BUCKET --region us-west-2 --create-bucket-configuration LocationConstraint=us-west-2 69 | ``` 70 | 71 | Because S3 bucket names are globally unique, don't use that exact bucket name. Just ensure that: 72 | 73 | 1. You note which region you created your bucket it, as you'll need to also deploy your application to this region later. If you don't specify a region, it can be assumed to be `us-east-1`. Or, if using an existing bucket, note the region it was created in. 74 | 2. You keep track of the bucket name you used. 75 | 76 | ## Exercise 1: Your First AWS Lambda Web API Method 77 | 78 | In this exercise, we're going to create our first web-based serverless APIs. We're also going to incorporate an Amazon DynamoDB database, and the `aws-record` gem to interact with the database. 79 | 80 | ### 1.1: Create the SAM Project 81 | 82 | Run `sam init --runtime ruby2.5 --name railsconf2019` to create the project we're going to use for the remainder of the exercise. You can substitute any name you like. You will see the following file structure within your project folder: 83 | 84 | ``` 85 | ├── Gemfile 86 | ├── README.md 87 | ├── hello_world 88 | │   ├── Gemfile 89 | │   └── app.rb 90 | ├── template.yaml 91 | └── tests 92 | └── unit 93 | └── test_handler.rb 94 | ``` 95 | 96 | You'll notice that we have two Gemfiles in this case. The mental model to use for this is that the root Gemfile is used for testing, and the Gemfile within your function directory ('hello_world' in this case, but we're going to replace it) determines what is actually deployed to AWS Lambda. 97 | 98 | The template generated is a fully operational example app, but we're going to clear away some of what was generated for us and recreate it ourselves to better understand what's going on. 99 | 100 | For the moment, we're going to: 101 | 102 | 1. Delete the `hello_world` folder and all its contents. 103 | 2. Delete the `tests` folder and all its contents. 104 | 3. For the top level `Gemfile`, reduce it to test dependencies only: 105 | 106 | `Gemfile` 107 | ```ruby 108 | source "https://rubygems.org" 109 | 110 | # Load app Gemfile dependencies 111 | eval(IO.read("app/Gemfile"), binding) 112 | 113 | group :test do 114 | gem 'minitest', '~> 5.11' 115 | end 116 | ``` 117 | 118 | Finally, delete the stub function from `template.yaml`, leaving us with the following: 119 | 120 | `template.yaml` 121 | ```yaml 122 | AWSTemplateFormatVersion: '2010-09-09' 123 | Transform: AWS::Serverless-2016-10-31 124 | Description: > 125 | railsconf2019 126 | 127 | Sample SAM Template for railsconf2019 128 | 129 | Globals: 130 | Function: 131 | Timeout: 3 132 | 133 | Resources: 134 | 135 | Outputs: 136 | ApiEndpoint: 137 | Description: "API Gateway endpoint URL for Prod stage" 138 | Value: !Sub "https://${ServerlessRestApi}.execute-api.${AWS::Region}.amazonaws.com/Prod/" 139 | ``` 140 | 141 | From this point, we will add in each component that we need. 142 | 143 | ### 1.2: Adding a DynamoDB Table 144 | 145 | Let's go ahead and create a new folder where our app definition will live: 146 | 147 | ``` 148 | mkdir app 149 | cd app/ 150 | touch Gemfile 151 | touch web_api.rb 152 | touch posts.rb 153 | ``` 154 | 155 | After we do this, we need to add our DynamoDB table to our application template, and define it in `posts.rb`. First, we're going to edit `template.yaml` in the root directory of our project, and add the following as our first resource: 156 | 157 | `template.yaml` 158 | ```yaml 159 | Resources: 160 | PostsTable: 161 | Type: AWS::Serverless::SimpleTable 162 | Properties: 163 | PrimaryKey: 164 | Name: post_uuid 165 | Type: String 166 | ``` 167 | 168 | We then need to create the equivalent table in `posts.rb` like so: 169 | 170 | `app/posts.rb` 171 | ```ruby 172 | require 'aws-record' 173 | 174 | class Posts 175 | include Aws::Record 176 | set_table_name(ENV["TABLE_NAME"]) 177 | string_attr :post_uuid, hash_key: true 178 | string_attr :title 179 | string_attr :body 180 | epoch_time_attr :created_at 181 | end 182 | ``` 183 | 184 | And, ensure the `app/Gemfile` has our required dependency: 185 | 186 | `app/Gemfile` 187 | ```ruby 188 | source "https://rubygems.org" 189 | 190 | gem 'aws-record', '~> 2' 191 | ``` 192 | 193 | Now, if you run `bundle install` in your project root directory, you'll have the dependencies you need to continue, and your table is ready. 194 | 195 | ### 1.3: Failing Unit Tests 196 | 197 | ``` 198 | mkdir -p tests/app 199 | cd tests/app 200 | touch test_web_api.rb 201 | ``` 202 | 203 | We want to test the `WebApi` suite of handlers, specifically for basic `index`, `get`, and `create` behavior. In this case, copy the following test file into `test_web_api.rb`, so you can see tests passing as you go: 204 | 205 | `tests/app/test_web_api.rb` 206 | ```ruby 207 | require 'minitest/autorun' 208 | require 'json' 209 | require_relative '../../app/web_api' 210 | 211 | class WebApiTest < Minitest::Test 212 | def test_index 213 | first_post_time = Time.at(1500000000).utc 214 | second_post_time = Time.at(1550000000).utc 215 | post_list = [ 216 | Posts.new( 217 | post_uuid: "a1", 218 | title: "First Post", 219 | body: "Hello, world!", 220 | created_at: first_post_time 221 | ), 222 | Posts.new( 223 | post_uuid: "b2", 224 | title: "Second Post", 225 | body: "Another post.", 226 | created_at: second_post_time 227 | ) 228 | ] 229 | record_collection = Minitest::Mock.new # mocking an ItemCollection 230 | record_collection.expect(:page, post_list, []) 231 | expected_body = { 232 | posts: [ 233 | { 234 | post_uuid: "a1", 235 | title: "First Post", 236 | body: "Hello, world!", 237 | created_at: first_post_time 238 | }, 239 | { 240 | post_uuid: "b2", 241 | title: "Second Post", 242 | body: "Another post.", 243 | created_at: second_post_time 244 | } 245 | ] 246 | }.to_json 247 | Posts.stub(:scan, record_collection) do 248 | actual = WebApi.index(event: {}, context: nil) 249 | assert_equal(expected_body, actual[:body]) 250 | assert_equal(200, actual[:statusCode]) 251 | end 252 | record_collection.verify 253 | end 254 | 255 | def test_get 256 | post_time = Time.at(1500000000).utc 257 | post = Posts.new( 258 | post_uuid: "a1", 259 | title: "First Post", 260 | body: "Hello, world!", 261 | created_at: post_time 262 | ) 263 | expected = { 264 | statusCode: 200, 265 | body: { 266 | post: post.to_h 267 | }.to_json 268 | } 269 | Posts.stub(:find, post) do 270 | actual = WebApi.get( 271 | event: { 272 | "pathParameters" => { 273 | "uuid" => "a1" 274 | } 275 | }, 276 | context: nil 277 | ) 278 | assert_equal(expected, actual) 279 | end 280 | Posts.stub(:find, nil) do 281 | actual = WebApi.get( 282 | event: { 283 | "pathParameters" => { 284 | "uuid" => "a1" 285 | } 286 | }, 287 | context: nil 288 | ) 289 | assert_equal(404, actual[:statusCode]) 290 | end 291 | end 292 | 293 | def test_create 294 | input_event = { 295 | "body" => { 296 | title: "New Post", 297 | body: "Content!" 298 | }.to_json 299 | } 300 | mock = Minitest::Mock.new 301 | mock.expect(:save, true) 302 | mock.expect(:to_h, {}) # we don't check the return value in this test 303 | Posts.stub(:new, mock) do 304 | SecureRandom.stub(:uuid, "abc123") do 305 | Time.stub(:now, Time.at(1500000000)) do 306 | WebApi.create(event: input_event, context: nil) 307 | end 308 | end 309 | end 310 | mock.verify 311 | end 312 | end 313 | ``` 314 | 315 | ### 1.4: Implementation of the Web API 316 | 317 | We're going to be putting our AWS Lambda handlers into a class, so let's put a class skeleton into `web_api.rb`: 318 | 319 | `app/web_api.rb` 320 | ```ruby 321 | require_relative 'posts' 322 | 323 | class WebApi 324 | class << self 325 | end 326 | end 327 | ``` 328 | 329 | If we run `ruby tests/app/test_web_api.rb` we will now find that our three handlers are not defined, so let's flesh them out one by one and get our tests to pass. 330 | 331 | #### 1.4.1: Index Function 332 | 333 | We're going to start by putting the method signature for an AWS Lambda handler into the `WebApi` class like so: 334 | 335 | `app/web_api.rb` 336 | ```ruby 337 | class WebApi 338 | class << self 339 | def index(event:,context:) 340 | end 341 | end 342 | end 343 | ``` 344 | 345 | There are a few important notes to understand before we proceed: 346 | 347 | * AWS Lambda handlers inside a class should be class methods, not instance methods. When we define the handler `web_api.WebApi.index`, we're telling AWS Lambda to load the file `web_api.rb`, and then call `WebApi.index`. If you define an instance method, you'll end up with a runtime exception (or with our test suite, the tests won't pass). 348 | * Your handler needs to accept the `event:` keyname argument (or hash argument). The AWS Lambda runtime uses this parameter to pass the calling event, which in this case will be a web request from Amazon API Gateway. 349 | * Your handler also needs to accept the `context:` keyname argument (or hash argument, which for this method you can ignore). The context object provides [a number of useful helper methods](https://docs.aws.amazon.com/lambda/latest/dg/ruby-context.html) which can be especially helpful for debugging or for long-running functions that may need to be aware of the execution deadline. We won't need any context methods for this workshop, but they're handy to know. 350 | 351 | To implement the `index` method, we're going to scan over our DynamoDB table and return JSON representations of the posts within. Conceptually, this is an API which takes no arguments, and the response hash includes the `posts` keyword for which the value is an array with up to 25 posts: 352 | 353 | `app/web_api.rb` 354 | ```ruby 355 | def index(event:,context:) 356 | posts = Posts.scan(limit: 25).page.map { |p| p.to_h } 357 | return { 358 | statusCode: 200, 359 | body: { posts: posts }.to_json 360 | } 361 | end 362 | ``` 363 | 364 | You should see `ruby tests/app/test_web_api.rb` passing for the index test case. 365 | 366 | A couple of notes about this implementation: 367 | 368 | * This implementation, to avoid a full table scan, returns only a single 'page' of up to 25 results. The `aws-record` library provides this and other methods to help you implement paginated APIs, but we won't worry about adding pagination yet. 369 | * API Gateway expects your AWS Lambda function to return a hash, with the following keys: 370 | * `statusCode`: The HTTP status code to return to the caller. 371 | * `body`: Needs to be a string, which will be returned to the caller. In our case, we call `#to_json` on the hash representation of our response. 372 | * `headers`: If desired, you can also specify response headers. This is optional and we are choosing not to do so in this handler. 373 | 374 | Finally, we need to add our handler to our `template.yaml` file, so that it can be packaged and deployed. We put the following under the `Resources` key: 375 | 376 | `template.yaml` 377 | ```yaml 378 | Resources: 379 | WebApiIndex: 380 | Type: AWS::Serverless::Function 381 | Properties: 382 | CodeUri: app/ 383 | Handler: web_api.WebApi.index 384 | Runtime: ruby2.5 385 | Policies: 386 | - DynamoDBReadPolicy: 387 | TableName: !Ref PostsTable 388 | Environment: 389 | Variables: 390 | TABLE_NAME: !Ref PostsTable 391 | Events: 392 | ApiIndex: 393 | Type: Api 394 | Properties: 395 | Path: /posts 396 | Method: GET 397 | ``` 398 | 399 | #### Bonus: Deploy and Run Your First Lambda Function 400 | 401 | At this point, though we haven't completed all of our APIs, we can deploy and see this running on AWS! 402 | 403 | ```shell 404 | sam build 405 | sam package --template-file .aws-sam/build/template.yaml --output-template-file packaged.yaml --s3-bucket $RAILSCONF_SOURCE_BUCKET 406 | sam deploy --template-file packaged.yaml --stack-name railsconf2019 --capabilities CAPABILITY_IAM --region us-west-2 407 | aws cloudformation describe-stacks --stack-name railsconf2019 --region us-west-2 --query 'Stacks[].Outputs' 408 | ``` 409 | 410 | If you did not set `$RAILSCONF_SOURCE_BUCKET` in Exercise 0, make sure you set it or use your source bucket name above. 411 | 412 | The final command should give you an endpoint for your API, and if you add `posts` to the end of it for your path, you can call it with curl like so: 413 | 414 | ```shell 415 | export RAILSCONF_API_ENDPOINT=https://12my34api56id.execute-api.us-west-2.amazonaws.com/Prod/posts 416 | curl $RAILSCONF_API_ENDPOINT 417 | ``` 418 | 419 | Make sure to substitute your actual API path here as returned from CloudFormation. You should see an empty response like `{"posts":[]}`, but you've deployed a function to AWS Lambda! We'll finish the implementation now. 420 | 421 | #### 1.4.2: Get Function 422 | 423 | At this point, we can see a pattern for adding new web API functions: 424 | 425 | 1. Create a new handler function that returns an API Gateway-formatted response hash. 426 | 2. Add the handler definition to `template.yaml`. 427 | 428 | Our `get` function will also look at parsing raw event inputs. One thing to keep in mind is that we're doing this in the most manual way. Libraries built on top of AWS Lambda can and do abstract this away for you. 429 | 430 | Let's start with the code we need: 431 | 432 | `app/web_api.rb` 433 | ```ruby 434 | def get(event:,context:) 435 | post_id = event["pathParameters"]["uuid"] 436 | post = Posts.find(post_uuid: post_id) 437 | if post 438 | return { 439 | statusCode: 200, 440 | body: { post: post.to_h }.to_json 441 | } 442 | else 443 | return { 444 | statusCode: 404, 445 | body: { error: "Post #{post_id} not found!" }.to_json 446 | } 447 | end 448 | end 449 | ``` 450 | 451 | After you confirm you're down to a single error when running `ruby tests/app/test_web_api.rb` we can add the resource to our `template.yaml` file: 452 | 453 | `template.yaml` 454 | ```yaml 455 | WebApiGet: 456 | Type: AWS::Serverless::Function 457 | Properties: 458 | CodeUri: app/ 459 | Handler: web_api.WebApi.get 460 | Runtime: ruby2.5 461 | Policies: 462 | - DynamoDBReadPolicy: 463 | TableName: !Ref PostsTable 464 | Environment: 465 | Variables: 466 | TABLE_NAME: !Ref PostsTable 467 | Events: 468 | ApiGet: 469 | Type: Api 470 | Properties: 471 | Path: /posts/{uuid} 472 | Method: GET 473 | ``` 474 | 475 | One key difference can be found in the `Path` value we set. When you use the `/posts/{uuid}` syntax, API Gateway will automatically parse and pass along values put in the actual path. So if a user makes a GET request to `/posts/my-post-id`, the `event["pathParameters"]["uuid"]` value will be the string `'my-post-id'`. 476 | 477 | #### 1.4.3: Create Function 478 | 479 | When creating a new post, we have to think a bit more about input validation. Users pass in JSON as the request body, which we parse for the post field values. Since we only accept two parameters from our user, we can fairly easily implement the allowed parameters pattern. 480 | 481 | `app/web_api.rb` 482 | ```ruby 483 | def create(event:,context:) 484 | params = _create_params(event["body"]) 485 | params[:post_uuid] = SecureRandom.uuid 486 | params[:created_at] = Time.now 487 | post = Posts.new(params) 488 | if post.save 489 | return { 490 | statusCode: 200, 491 | body: { post: post.to_h }.to_json 492 | } 493 | else 494 | return { 495 | statusCode: 500, 496 | body: { error: "Failed to create new post." } 497 | } 498 | end 499 | end 500 | 501 | private 502 | def _create_params(body_input) 503 | ret = {} 504 | json = JSON.parse(body_input, symbolize_names: true) 505 | ret[:title] = json[:title] 506 | ret[:body] = json[:body] 507 | ret 508 | end 509 | ``` 510 | 511 | (Note: The unit test mocking behavior is a bit particular to exactly how you write the function definition. While it's perfectly valid to set `:post_uuid` and `:created_at` on the new `post` object before saving, you would have to set new mock expectations if you do so.) 512 | 513 | After we add our final function definition to `template.yaml`, we are ready to deploy to AWS: 514 | 515 | `template.yaml` 516 | ```yaml 517 | WebApiCreate: 518 | Type: AWS::Serverless::Function 519 | Properties: 520 | CodeUri: app/ 521 | Handler: web_api.WebApi.create 522 | Runtime: ruby2.5 523 | Policies: 524 | - DynamoDBCrudPolicy: 525 | TableName: !Ref PostsTable 526 | Environment: 527 | Variables: 528 | TABLE_NAME: !Ref PostsTable 529 | Events: 530 | ApiCreate: 531 | Type: Api 532 | Properties: 533 | Path: /posts 534 | Method: POST 535 | ``` 536 | 537 | Note here that we are using a different policy for DynamoDB access which allows for calling the `#put_item` API on our table. This entire time, we've been ensuring that our AWS Lambda functions have the minimal set of permissions necessary for them to function properly, an important best practice. 538 | 539 | ### 1.5: Deploying to AWS 540 | 541 | Run the following set of commands, using the bucket from Exercise 0, and the region of the bucket for `us-west-2` if you used another region. The region must match the region of the bucket in which you're storing your function source. 542 | 543 | ``` 544 | sam build 545 | sam package --template-file .aws-sam/build/template.yaml --output-template-file packaged.yaml --s3-bucket $RAILSCONF_SOURCE_BUCKET 546 | sam deploy --template-file packaged.yaml --stack-name railsconf2019 --capabilities CAPABILITY_IAM --region us-west-2 547 | aws cloudformation describe-stacks --stack-name railsconf2019 --region us-west-2 --query 'Stacks[].Outputs' 548 | ``` 549 | 550 | The final command should include your API endpoint, which you can call with curl. For example: 551 | 552 | ``` 553 | export RAILSCONF_API_ENDPOINT=https://12my34api56id.execute-api.us-west-2.amazonaws.com/Prod/posts 554 | curl $RAILSCONF_API_ENDPOINT 555 | ``` 556 | 557 | That second command should return `{"posts":[]}`, which is coming from AWS Lambda! 558 | 559 | ### 1.6: Using Your API 560 | 561 | Try the following commands: 562 | 563 | ``` 564 | curl $RAILSCONF_API_ENDPOINT/missingid 565 | curl -d '{"title":"First Post!","body":"Hello, Lambda!"}' $RAILSCONF_API_ENDPOINT 566 | curl $RAILSCONF_API_ENDPOINT/uuidfromlastresponse 567 | curl -d '{"title":"No Body Post"}' $RAILSCONF_API_ENDPOINT 568 | curl $RAILSCONF_API_ENDPOINT 569 | ``` 570 | 571 | As you run these commands, you can see your JSON-based serverless API in action! 572 | 573 | ## Exercise 2: Your First AWS Lambda Event-Trigger Methods 574 | 575 | AWS Lambda has multiple ways to invoke handlers. You can directly invoke via the AWS SDKs/CLI/Console, you can invoke as a web API (like we have done in Exercise 1), and you can also trigger your handlers from a number of different event sources. Some examples include: 576 | 577 | * Amazon S3 Object Lifecycle Events 578 | * Amazon SQS Queue Processing 579 | * Amazon SNS Event Processing 580 | * Amazon Kinesis Data Streams 581 | * [And many more...](https://docs.aws.amazon.com/lambda/latest/dg/lambda-services.html) 582 | 583 | We're going to try out Amazon SQS and Amazon CloudWatch Logs events. 584 | 585 | ### 2.1: Unit Test Suite 586 | 587 | Create the following as `tests/app/test_event_handlers.rb`, to provide a basic test suite for the event handler function we will write. 588 | 589 | `tests/app/test_event_handlers.rb` 590 | ```ruby 591 | require 'minitest/autorun' 592 | require_relative '../../app/event_handlers' 593 | 594 | class EventHandlersTest < Minitest::Test 595 | def test_delete_all_posts 596 | input_event = { 597 | "Records" => [ 598 | { 599 | "messageId" => SecureRandom.uuid, 600 | "body" => "DELETE_ALL", 601 | "md5OfBody" => "319f263fe809cba0eb00f8977a972740" 602 | } 603 | ] 604 | } 605 | mock_post_a = Minitest::Mock.new 606 | mock_post_b = Minitest::Mock.new 607 | mock_post_c = Minitest::Mock.new 608 | post_list = [ 609 | mock_post_a, 610 | mock_post_b, 611 | mock_post_c 612 | ] 613 | mock_post_a.expect(:delete!, nil, []) 614 | mock_post_b.expect(:delete!, nil, []) 615 | mock_post_c.expect(:delete!, nil, []) 616 | Posts.stub(:scan, post_list) do 617 | EventHandlers.delete_all_posts(event: input_event, context: nil) 618 | end 619 | mock_post_a.verify 620 | mock_post_b.verify 621 | mock_post_c.verify 622 | end 623 | 624 | def test_delete_all_posts_bad_event 625 | input_event = { 626 | "Records" => [ 627 | { 628 | "messageId" => SecureRandom.uuid, 629 | "body" => "BAD_MESSAGE", 630 | "md5OfBody" => "6af3db524c14f32b6f183d51c8d04e8a" 631 | } 632 | ] 633 | } 634 | assert_raises(StandardError) { 635 | EventHandlers.delete_all_posts(event: input_event, context: nil) 636 | } 637 | end 638 | end 639 | ``` 640 | 641 | Note here that the structure of an SQS event message is different than an API Gateway event. 642 | 643 | ### 2.2: Creating an SQS Queue 644 | 645 | For creating an SQS queue, all we need to do is add this to the `Resources` of our `template.yaml` file: 646 | 647 | `template.yaml` 648 | ```yaml 649 | DeletePostQueue: 650 | Type: AWS::SQS::Queue 651 | ``` 652 | 653 | We'll reference this resource when defining other methods. 654 | 655 | ### 2.3: Creating an SQS-Triggered Function 656 | 657 | Now, we can define the `app/event_handlers.rb` file, which will contain our handlers for event-based methods. In it, we're going to define a method which: 658 | 659 | 1. Consumes the Amazon SQS event message format. 660 | 2. Validates that the message body is the exact message we expect, or raises an exception. 661 | 3. If the message body matches, iterate over every post in our database and delete it. 662 | 663 | `app/event_handlers.rb` 664 | ```ruby 665 | require_relative 'posts' 666 | require 'json' 667 | 668 | class EventHandlers 669 | class << self 670 | def delete_all_posts(event:,context:) 671 | event["Records"].each do |record| 672 | if record["body"] == "DELETE_ALL" 673 | count = 0 674 | begin 675 | Posts.scan.each do |post| 676 | post.delete! 677 | count += 1 678 | end 679 | rescue Aws::DynamoDB::Errors => e 680 | puts "[ERROR] Raised #{e.class} after deleting #{count} entries." 681 | raise(e) 682 | end 683 | else 684 | puts "[ERROR] Unsupported queue event: #{record.to_json}" 685 | raise StandardError.new( 686 | "Unsupported queue command: #{record["body"]}" 687 | ) 688 | end 689 | end 690 | end 691 | end 692 | end 693 | ``` 694 | 695 | Then as before, we add our new method under the `Resources` section of our `template.yaml` file. Note here that the `Events` section has changed to support a different event source type (SQS rather than Api), but otherwise much of the format remains the same. 696 | 697 | `template.yaml` 698 | ```yaml 699 | DeleteAllEventHandler: 700 | Type: AWS::Serverless::Function 701 | Properties: 702 | CodeUri: app/ 703 | Handler: event_handlers.EventHandlers.delete_all_posts 704 | Runtime: ruby2.5 705 | Policies: 706 | - DynamoDBCrudPolicy: 707 | TableName: !Ref PostsTable 708 | Environment: 709 | Variables: 710 | TABLE_NAME: !Ref PostsTable 711 | Events: 712 | QueueEvent: 713 | Type: SQS 714 | Properties: 715 | Queue: !GetAtt DeletePostQueue.Arn 716 | ``` 717 | 718 | ### 2.4: Creating the "Delete All" Web API 719 | 720 | Next, we want a way to trigger our event-based function. That's going to be a handler in our `web_api.rb` class, and a couple of additional private methods. What our handler will do is send off a "DELETE_ALL" message to Amazon SQS, and then return a `204` success code: 721 | 722 | `app/web_api.rb` 723 | ```ruby 724 | def delete_all(event:,context:) 725 | _sqs_client.send_message( 726 | queue_url: _sqs_queue_url, 727 | message_body: "DELETE_ALL" 728 | ) 729 | return { 730 | statusCode: 204 731 | } 732 | end 733 | 734 | private 735 | def _sqs_client 736 | require 'aws-sdk-sqs' 737 | @@sqs_client ||= Aws::SQS::Client.new 738 | @@sqs_client 739 | end 740 | 741 | def _sqs_queue_url 742 | ENV["SQS_QUEUE_URL"] 743 | end 744 | ``` 745 | 746 | Now, we COULD perform the entire deletion process within this handler, and only return after completion. However, in the case of a large database with many entries, that could take a very long time, especially from the perspective of a calling user! You could imagine the use case of a post/comments relationship, where deleting a post deletes all comments. Perhaps your deletion function for a post deletes the post immediately (which could render all comments unviewable), but rather than wait to delete all comments individually, it cleans them up later as a delayed job. This is the pattern we are implementing here. 747 | 748 | If you wanted to take this further, you could actually create a "Deletion Job" in a database, include that job ID in your SQS message, and provide APIs to track the status of a deletion job. This is not unlike the pattern seen in many AWS APIs, where very long running jobs will have a creation/initiation API, and "Describe" APIs to track the status of a job. Implementing this is a *BONUS EXERCISE* you could try after the workshop, if desired. 749 | 750 | For now, we just need to add our new web API to `Resources` in the `template.yaml` file: 751 | 752 | `template.yaml` 753 | ```yaml 754 | DeleteAllHandler: 755 | Type: AWS::Serverless::Function 756 | Properties: 757 | CodeUri: app/ 758 | Handler: web_api.WebApi.delete_all 759 | Runtime: ruby2.5 760 | Environment: 761 | Variables: 762 | SQS_QUEUE_URL: !Ref DeletePostQueue 763 | Policies: 764 | SQSSendMessagePolicy: 765 | QueueName: !GetAtt DeletePostQueue.QueueName 766 | Events: 767 | DeleteAllApi: 768 | Type: Api 769 | Properties: 770 | Path: /posts 771 | Method: DELETE 772 | ``` 773 | 774 | ### 2.5: Deploying and Testing 775 | 776 | We can build and deploy our set of handlers using the same pattern as before: 777 | 778 | ``` 779 | sam build 780 | sam package --template-file .aws-sam/build/template.yaml --output-template-file packaged.yaml --s3-bucket $RAILSCONF_SOURCE_BUCKET 781 | sam deploy --template-file packaged.yaml --stack-name railsconf2019 --capabilities CAPABILITY_IAM --region us-west-2 782 | aws cloudformation describe-stacks --stack-name railsconf2019 --region us-west-2 --query 'Stacks[].Outputs' 783 | ``` 784 | 785 | Then, ensure we are noting our API endpoint from `describe-stacks` (it will not have changed if you've already stored it). 786 | 787 | ``` 788 | export RAILSCONF_API_ENDPOINT=https://12my34api56id.execute-api.us-west-2.amazonaws.com/Prod/posts 789 | curl $RAILSCONF_API_ENDPOINT 790 | ``` 791 | 792 | Now, try creating a few posts, triggering a deletion job, and then checking the index call again. 793 | 794 | ``` 795 | curl -d '{"title":"One"}' $RAILSCONF_API_ENDPOINT 796 | curl -d '{"title":"Two"}' $RAILSCONF_API_ENDPOINT 797 | curl -d '{"title":"Three"}' $RAILSCONF_API_ENDPOINT 798 | curl $RAILSCONF_API_ENDPOINT 799 | curl -X DELETE $RAILSCONF_API_ENDPOINT 800 | curl $RAILSCONF_API_ENDPOINT 801 | ``` 802 | 803 | The deletion generally goes from "placed in the queue" to "complete" in a fraction of a second, so the final command will likely show an empty result. 804 | 805 | ## Exercise 3: Creating CloudWatch Alarms 806 | 807 | In this workshop, we're trying to not only build our first serverless applications, but get an idea of how to productionize our application. Building a CI/CD pipeline is part of that. The next part is visibility. 808 | 809 | One mental transition to building event-based functions is the lack of immediately visible feedback. When you open a webpage or call a JSON API, if it fails it tends to be immediately obvious. What if our SQS event handler breaks? That's where alarms come in. 810 | 811 | ### 3.1: Creating an Error Alarm 812 | 813 | The intention of this alarm is to raise an alarm when your function begins to throw errors. We're going to put this on the "Index" API, but the same pattern applies essentially to any AWS Lambda function we have. 814 | 815 | Let's add the following to our `template.yaml` file: 816 | 817 | `template.yaml` 818 | ```yaml 819 | WebApiIndexErrorAlarm: 820 | Type: AWS::CloudWatch::Alarm 821 | Properties: 822 | AlarmName: RailsconfApiIndexErrors 823 | Namespace: AWS/Lambda 824 | MetricName: Errors 825 | Dimensions: 826 | - Name: FunctionName 827 | Value: !Ref WebApiIndex 828 | ComparisonOperator: GreaterThanOrEqualToThreshold 829 | Statistic: Sum 830 | Threshold: 1 831 | EvaluationPeriods: 3 832 | Period: 60 833 | TreatMissingData: missing 834 | ``` 835 | 836 | To deploy, just zip and upload your source to your S3 bucket, and let your deployment pipeline do it's thing! We can do the same now after each change we make. 837 | 838 | ### 3.2: Creating a Latency Alarm 839 | 840 | One important feature to keep in mind when designing latency alarms is that you can use extended statistics such as p-thresholds for latency, which are a more useful metric than averages for most use cases. We're going to build two latency alarms, p50 and p99 for our "Index" API function. 841 | 842 | `template.yaml` 843 | ```yaml 844 | WebApiIndexLatencyP50Alarm: 845 | Type: AWS::CloudWatch::Alarm 846 | Properties: 847 | AlarmName: RailsconfApiIndexLatencyP50 848 | Namespace: AWS/Lambda 849 | MetricName: Duration 850 | Dimensions: 851 | - Name: FunctionName 852 | Value: !Ref WebApiIndex 853 | ComparisonOperator: GreaterThanOrEqualToThreshold 854 | ExtendedStatistic: p50 855 | Threshold: 250 856 | Unit: Milliseconds 857 | EvaluationPeriods: 3 858 | Period: 60 859 | TreatMissingData: missing 860 | WebApiIndexLatencyP99Alarm: 861 | Type: AWS::CloudWatch::Alarm 862 | Properties: 863 | AlarmName: RailsconfApiIndexLatencyP99 864 | Namespace: AWS/Lambda 865 | MetricName: Duration 866 | Dimensions: 867 | - Name: FunctionName 868 | Value: !Ref WebApiIndex 869 | ComparisonOperator: GreaterThanOrEqualToThreshold 870 | ExtendedStatistic: p99 871 | Threshold: 1000 872 | Unit: Milliseconds 873 | EvaluationPeriods: 3 874 | Period: 60 875 | TreatMissingData: missing 876 | ``` 877 | 878 | There are a couple pieces of configuration here that merit extra attention: 879 | 880 | - We chose P50 and P99 thresholds of 250ms and 1000ms, respectively. These aren't universal numbers, they heavily depend on what your functions do, how much memory you assign to your function, and so on. A good rule of thumb is that you should observe your actual P50/P99 metrics over time, and set thresholds fairly close to observed numbers. Sudden increases in latency mean a sudden degredation in your user experience and possible an underlying issue, which is why we have these alarms in the first place. Overly conservative thresholds that never trigger aren't very useful. 881 | - How you treat missing data is an important consideration. If you're building a low-traffic function that's only called occasionally, it would make sense to treat missing data points as "missing", which essentially means an evaluation period with no data is skipped. However, if your application is high traffic, you should use "breaching" for your missing data policy, which treats any evaluation period with missing data as if it were a failing metric. In this manner, your alarms will activate if your function appears to be down/not taking traffic. 882 | 883 | ### 3.3: Creating an SQS Dead Letter Queue and Alarm 884 | 885 | One good way to get visibility into failures in event-based functions that use Amazon SQS is to create a dead letter queue, where messages that repeatedly fail to process are placed for manual investigation. Combined with an alarm, you can be alerted in the event that a message has failed its maximum retries. 886 | 887 | `template.yaml` 888 | ```yaml 889 | DeletePostQueue: 890 | Type: AWS::SQS::Queue 891 | Properties: 892 | RedrivePolicy: 893 | deadLetterTargetArn: !GetAtt DeletePostDeadLetterQueue.Arn 894 | maxReceiveCount: 5 895 | DeletePostDeadLetterQueue: 896 | Type: AWS::SQS::Queue 897 | DeletePostDLQAlarm: 898 | Type: AWS::CloudWatch::Alarm 899 | Properties: 900 | AlarmName: RailsconfDeletePostDLQ 901 | Namespace: AWS/SQS 902 | MetricName: ApproximateNumberOfMessagesVisible 903 | Dimensions: 904 | - Name: QueueName 905 | Value: !GetAtt DeletePostDeadLetterQueue.QueueName 906 | ComparisonOperator: GreaterThanOrEqualToThreshold 907 | Statistic: Sum 908 | Threshold: 1 909 | EvaluationPeriods: 5 910 | Period: 60 911 | TreatMissingData: breaching 912 | ``` 913 | 914 | What does this do? 915 | 916 | - Creates a second SQS queue to store "failed" messages (known as a "Dead Letter Queue"). 917 | - Configures our `DeletePostQueue` to drive any messages that are received more than 5 times to the "Dead Letter Queue". 918 | - Creates an alarm on the DLQ which, if for 5 straight minutes the queue has at least one message waiting, triggers an alarm. 919 | 920 | ## Exercise 4: Creating a CI/CD Pipeline 921 | 922 | We've got tests, and a process for building, packaging, and deploying our application. The next step is to create a pipeline that will test and deploy our changes anytime we push our source. 923 | 924 | ### 4.1: Creating the Pipeline Template 925 | 926 | Included here is a CloudFormation template that creates a 3-step deployment process via AWS CodePipeline. 927 | 928 | 1. Source code is uploaded as a zip file (`railsconf-source.zip` or another name you can specify) and uploaded to a source bucket whose name you also specify. 929 | - A note: You can absolutely use AWS CodeCommit or GitHub as source actions. S3 is what we're going with in this workshop because it's simple to do with the tools we know we have installed. 930 | 2. Through AWS CodeBuild, your unit tests are run, and then your code is packaged for deployment. 931 | 3. Finally, AWS CloudFormation is used to deploy your changes to the same template we have already been using. 932 | 933 | I recommend making a new folder (for e.g., `pipeline`), and create this file as `pipeline-template.yml` inside that folder. This is not the same template you use for your application, it's going to build and deploy your application template. 934 | 935 | `pipeline/pipeline-template.yml` 936 | ```yaml 937 | Parameters: 938 | AppStackName: 939 | Type: String 940 | Description: "The name of the CloudFormation stack you have deployed your application to." 941 | Default: railsconf2019 942 | PipelineName: 943 | Type: String 944 | Description: "Name of the CodePipeline to create." 945 | SourceBucketName: 946 | Type: String 947 | Description: "S3 bucket name to use for the source code." 948 | SourceZipKey: 949 | Type: String 950 | Description: "S3 key in the Source Bucket where source code is stored." 951 | Default: railsconf-source.zip 952 | Resources: 953 | SourceCodeBucket: 954 | Type: AWS::S3::Bucket 955 | Properties: 956 | BucketName: !Ref SourceBucketName 957 | VersioningConfiguration: 958 | Status: Enabled 959 | DeletionPolicy: Retain 960 | LambdaPipelineArtifactsBucket: 961 | Type: AWS::S3::Bucket 962 | DeletionPolicy: Retain 963 | LambdaPipelineRole: 964 | Type: AWS::IAM::Role 965 | Properties: 966 | AssumeRolePolicyDocument: 967 | Statement: 968 | - Action: sts:AssumeRole 969 | Effect: Allow 970 | Principal: 971 | Service: codepipeline.amazonaws.com 972 | Version: "2012-10-17" 973 | LambdaPipelineRoleDefaultPolicy: 974 | Type: AWS::IAM::Policy 975 | Properties: 976 | PolicyDocument: 977 | Statement: 978 | - Action: 979 | - s3:GetObject* 980 | - s3:GetBucket* 981 | - s3:List* 982 | - s3:DeleteObject* 983 | - s3:PutObject* 984 | - s3:Abort* 985 | Effect: Allow 986 | Resource: 987 | - Fn::GetAtt: 988 | - LambdaPipelineArtifactsBucket 989 | - Arn 990 | - Fn::Join: 991 | - "" 992 | - - Fn::GetAtt: 993 | - LambdaPipelineArtifactsBucket 994 | - Arn 995 | - /* 996 | - Action: 997 | - s3:GetObject* 998 | - s3:GetBucket* 999 | - s3:List* 1000 | Effect: Allow 1001 | Resource: 1002 | - Fn::GetAtt: 1003 | - SourceCodeBucket 1004 | - Arn 1005 | - Fn::Join: 1006 | - "" 1007 | - - Fn::GetAtt: 1008 | - SourceCodeBucket 1009 | - Arn 1010 | - /* 1011 | - Action: 1012 | - codebuild:BatchGetBuilds 1013 | - codebuild:StartBuild 1014 | - codebuild:StopBuild 1015 | Effect: Allow 1016 | Resource: 1017 | Fn::GetAtt: 1018 | - BuildProject 1019 | - Arn 1020 | - Action: iam:PassRole 1021 | Effect: Allow 1022 | Resource: 1023 | Fn::GetAtt: 1024 | - CloudFormationDeploymentRole 1025 | - Arn 1026 | - Action: 1027 | - cloudformation:CreateStack 1028 | - cloudformation:DescribeStack* 1029 | - cloudformation:GetStackPolicy 1030 | - cloudformation:GetTemplate* 1031 | - cloudformation:SetStackPolicy 1032 | - cloudformation:UpdateStack 1033 | - cloudformation:ValidateTemplate 1034 | Effect: Allow 1035 | Resource: 1036 | Fn::Join: 1037 | - "" 1038 | - - "arn:" 1039 | - Ref: AWS::Partition 1040 | - ":cloudformation:" 1041 | - Ref: AWS::Region 1042 | - ":" 1043 | - Ref: AWS::AccountId 1044 | - ":stack/" 1045 | - Ref: AppStackName 1046 | - "/*" 1047 | Version: "2012-10-17" 1048 | PolicyName: LambdaPipelineRoleDefaultPolicy 1049 | Roles: 1050 | - Ref: LambdaPipelineRole 1051 | LambdaPipeline: 1052 | Type: AWS::CodePipeline::Pipeline 1053 | Properties: 1054 | RoleArn: 1055 | Fn::GetAtt: 1056 | - LambdaPipelineRole 1057 | - Arn 1058 | Stages: 1059 | - Actions: 1060 | - ActionTypeId: 1061 | Category: Source 1062 | Owner: AWS 1063 | Provider: S3 1064 | Version: "1" 1065 | Configuration: 1066 | S3Bucket: 1067 | Ref: SourceCodeBucket 1068 | S3ObjectKey: !Ref SourceZipKey 1069 | PollForSourceChanges: true 1070 | InputArtifacts: 1071 | [] 1072 | Name: S3Source 1073 | OutputArtifacts: 1074 | - Name: Artifact_InfrastructureStackS3Source 1075 | RunOrder: 1 1076 | Name: Source 1077 | - Actions: 1078 | - ActionTypeId: 1079 | Category: Build 1080 | Owner: AWS 1081 | Provider: CodeBuild 1082 | Version: "1" 1083 | Configuration: 1084 | ProjectName: 1085 | Ref: BuildProject 1086 | InputArtifacts: 1087 | - Name: Artifact_InfrastructureStackS3Source 1088 | Name: BuildAction 1089 | OutputArtifacts: 1090 | - Name: Artifact_InfrastructureStackBuildAction 1091 | RunOrder: 1 1092 | Name: Build 1093 | - Actions: 1094 | - ActionTypeId: 1095 | Category: Deploy 1096 | Owner: AWS 1097 | Provider: CloudFormation 1098 | Version: "1" 1099 | Configuration: 1100 | StackName: !Ref AppStackName 1101 | ActionMode: CREATE_UPDATE 1102 | TemplatePath: Artifact_InfrastructureStackBuildAction::packaged.yaml 1103 | Capabilities: CAPABILITY_IAM,CAPABILITY_AUTO_EXPAND 1104 | RoleArn: 1105 | Fn::GetAtt: 1106 | - CloudFormationDeploymentRole 1107 | - Arn 1108 | InputArtifacts: 1109 | - Name: Artifact_InfrastructureStackBuildAction 1110 | Name: CloudFrontDeployment 1111 | OutputArtifacts: 1112 | [] 1113 | RunOrder: 1 1114 | Name: Deploy 1115 | ArtifactStore: 1116 | Location: 1117 | Ref: LambdaPipelineArtifactsBucket 1118 | Type: S3 1119 | Name: !Ref PipelineName 1120 | DependsOn: 1121 | - LambdaPipelineRole 1122 | - LambdaPipelineRoleDefaultPolicy 1123 | BuildProjectRole: 1124 | Type: AWS::IAM::Role 1125 | Properties: 1126 | AssumeRolePolicyDocument: 1127 | Statement: 1128 | - Action: sts:AssumeRole 1129 | Effect: Allow 1130 | Principal: 1131 | Service: codebuild.amazonaws.com 1132 | Version: "2012-10-17" 1133 | BuildProjectRoleDefaultPolicy: 1134 | Type: AWS::IAM::Policy 1135 | Properties: 1136 | PolicyDocument: 1137 | Statement: 1138 | - Action: 1139 | - logs:CreateLogGroup 1140 | - logs:CreateLogStream 1141 | - logs:PutLogEvents 1142 | Effect: Allow 1143 | Resource: 1144 | - Fn::Join: 1145 | - "" 1146 | - - "arn:" 1147 | - Ref: AWS::Partition 1148 | - ":logs:" 1149 | - Ref: AWS::Region 1150 | - ":" 1151 | - Ref: AWS::AccountId 1152 | - :log-group:/aws/codebuild/ 1153 | - Ref: BuildProject 1154 | - Fn::Join: 1155 | - "" 1156 | - - "arn:" 1157 | - Ref: AWS::Partition 1158 | - ":logs:" 1159 | - Ref: AWS::Region 1160 | - ":" 1161 | - Ref: AWS::AccountId 1162 | - :log-group:/aws/codebuild/ 1163 | - Ref: BuildProject 1164 | - :* 1165 | - Action: 1166 | - s3:GetObject* 1167 | - s3:GetBucket* 1168 | - s3:List* 1169 | - s3:DeleteObject* 1170 | - s3:PutObject* 1171 | - s3:Abort* 1172 | Effect: Allow 1173 | Resource: 1174 | - Fn::GetAtt: 1175 | - LambdaPipelineArtifactsBucket 1176 | - Arn 1177 | - Fn::Join: 1178 | - "" 1179 | - - Fn::GetAtt: 1180 | - LambdaPipelineArtifactsBucket 1181 | - Arn 1182 | - /* 1183 | - Action: 1184 | - s3:GetObject* 1185 | - s3:GetBucket* 1186 | - s3:List* 1187 | - s3:DeleteObject* 1188 | - s3:PutObject* 1189 | - s3:Abort* 1190 | Effect: Allow 1191 | Resource: 1192 | - Fn::GetAtt: 1193 | - SourceCodeBucket 1194 | - Arn 1195 | - Fn::Join: 1196 | - "" 1197 | - - Fn::GetAtt: 1198 | - SourceCodeBucket 1199 | - Arn 1200 | - /* 1201 | Version: "2012-10-17" 1202 | PolicyName: BuildProjectRoleDefaultPolicy 1203 | Roles: 1204 | - Ref: BuildProjectRole 1205 | BuildProject: 1206 | Type: AWS::CodeBuild::Project 1207 | Properties: 1208 | Artifacts: 1209 | Type: CODEPIPELINE 1210 | Environment: 1211 | ComputeType: BUILD_GENERAL1_SMALL 1212 | Image: aws/codebuild/ruby:2.5.3 1213 | PrivilegedMode: false 1214 | Type: LINUX_CONTAINER 1215 | EnvironmentVariables: 1216 | - Name: SOURCE_BUCKET_NAME 1217 | Value: !Ref SourceBucketName 1218 | ServiceRole: 1219 | Fn::GetAtt: 1220 | - BuildProjectRole 1221 | - Arn 1222 | Source: 1223 | BuildSpec: buildspec.yml 1224 | Type: CODEPIPELINE 1225 | CloudFormationDeploymentRole: 1226 | Type: AWS::IAM::Role 1227 | Properties: 1228 | AssumeRolePolicyDocument: 1229 | Statement: 1230 | - Action: sts:AssumeRole 1231 | Effect: Allow 1232 | Principal: 1233 | Service: cloudformation.amazonaws.com 1234 | Version: "2012-10-17" 1235 | CloudFormationDeploymentRoleDefaultPolicy: 1236 | Type: AWS::IAM::Policy 1237 | Properties: 1238 | PolicyDocument: 1239 | Statement: 1240 | - Action: "*" 1241 | Effect: Allow 1242 | Resource: "*" 1243 | Version: "2012-10-17" 1244 | PolicyName: CloudFormationDeploymentRoleDefaultPolicy 1245 | Roles: 1246 | - Ref: CloudFormationDeploymentRole 1247 | ``` 1248 | 1249 | It's a large file, but essentially it's just a CodePipeline, the S3 bucket for the source action, the CodeBuild job, the deployment action, and the relevant least privileged roles needed to perform said actions. We are going to focus on how to wire up our app to use this. 1250 | 1251 | ### 4.2: Creating a buildspec.yml File 1252 | 1253 | In the root directory of our project (not in the `app/` folder), we're going to create a `buildspec.yml` file, which CodeBuild expects to know what operations to perform. 1254 | 1255 | Essentially, we are going to run our unit tests, and then perform the build and package steps manually. All we need to pass on to the next stage after this is the `packaged.yaml` file generated by the package step. 1256 | 1257 | `buildspec.yml` 1258 | ```yaml 1259 | version: 0.2 1260 | 1261 | phases: 1262 | build: 1263 | commands: 1264 | - bundle install 1265 | - ruby tests/app/test_web_api.rb 1266 | - ruby tests/app/test_event_handlers.rb 1267 | - cd app 1268 | - bundle install --gemfile Gemfile 1269 | - bundle install --gemfile Gemfile --deployment 1270 | - cd .. 1271 | - aws cloudformation package --template-file template.yaml --output-template-file packaged.yaml --s3-bucket $SOURCE_BUCKET_NAME 1272 | 1273 | artifacts: 1274 | type: zip 1275 | files: 1276 | - packaged.yaml 1277 | ``` 1278 | 1279 | Remember to substitute "YOUR SOURCE BUCKET" for the bucket you created. 1280 | 1281 | ### 4.3: Deploying the Pipeline 1282 | 1283 | Next up, we're going to create our pipeline. Select a unique name for your source bucket and keep a note of it as an environment variable or otherwise. 1284 | 1285 | ``` 1286 | export RAILSCONF_SOURCE_BUCKET=my-railsconf-source-bucket 1287 | aws cloudformation deploy --template-file pipeline-template.yml --stack-name railsconf2019-pipeline --parameter-overrides SourceBucketName=$RAILSCONF_SOURCE_BUCKET PipelineName=railsconf2019 --capabilities CAPABILITY_IAM 1288 | ``` 1289 | 1290 | ### 4.4: Running a Pipeline Deployment 1291 | 1292 | Once your pipeline is up and running, you simply need to zip and upload to your source bucket the files used in your build and deploy process. 1293 | 1294 | ``` 1295 | zip -r railsconf-source.zip Gemfile* app* buildspec.yml template.yaml tests* 1296 | aws s3 cp railsconf-source.zip s3://$RAILSCONF_SOURCE_BUCKET 1297 | ``` 1298 | 1299 | You can view your deploying in the [AWS Console page for AWS CodePipeline](https://us-west-2.console.aws.amazon.com/codesuite/codepipeline/pipelines) - make sure you select the region you created your pipeline in. 1300 | --------------------------------------------------------------------------------