├── .github ├── CODEOWNERS ├── ISSUE_TEMPLATE │ ├── bug_report.yml │ └── maintenance.yml ├── PULL_REQUEST_TEMPLATE.md ├── auto_assign.yml ├── boring-cyborg.yml ├── dependabot.yml ├── scripts │ ├── comment_on_large_pr.js │ ├── constants.js │ ├── download_pr_artifact.js │ ├── enforce_acknowledgment.js │ ├── label_missing_acknowledgement_section.js │ ├── label_missing_related_issue.js │ ├── label_pr_based_on_title.js │ ├── label_related_issue.js │ └── save_pr_details.js ├── semantic.yml └── workflows │ ├── auto_assign.yml │ ├── build_test.yml │ ├── codeql-analysis.yml │ ├── label_pr_on_title.yml │ ├── on_label_added.yml │ ├── on_merged_pr.yml │ ├── on_opened_pr.yml │ ├── record_pr.yml │ └── reusable_export_pr_details.yml ├── .gitignore ├── .npmrc ├── .prettierignore ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── docs ├── architecture.png └── workshop_logo.png ├── eslint.config.mjs ├── package.json ├── pnpm-lock.yaml ├── pnpm-workspace.yaml ├── unicorn_contracts ├── .gitignore ├── .npmignore ├── .prettierignore ├── .prettierrc.js ├── Makefile ├── README.md ├── api.yaml ├── eslint.config.mjs ├── integration │ ├── ContractStatusChanged.json │ ├── event-schemas.yaml │ └── subscriber-policies.yaml ├── jest.config.js ├── package.json ├── samconfig.toml ├── src │ └── contracts_service │ │ ├── Contract.ts │ │ ├── contractEventHandler.ts │ │ └── powertools.ts ├── template.yaml ├── tests │ ├── data │ │ └── contract_data.json │ ├── integration │ │ ├── create_contract.test.ts │ │ ├── helper.ts │ │ ├── transformations │ │ │ └── ddb_contract.jq │ │ └── update_contract.test.ts │ ├── transformations │ │ └── ddb_contract.jq │ └── unit │ │ └── contractEventHandler.test.ts └── tsconfig.json ├── unicorn_properties ├── .gitignore ├── .npmignore ├── .prettierignore ├── .prettierrc.js ├── Makefile ├── README.md ├── eslint.config.mjs ├── integration │ ├── PublicationEvaluationCompleted.json │ ├── event-schemas.yaml │ ├── subscriber-policies.yaml │ └── subscriptions.yaml ├── jest.config.js ├── package.json ├── samconfig.toml ├── src │ ├── properties_service │ │ ├── contractStatusChangedEventHandler.ts │ │ ├── powertools.ts │ │ ├── propertiesApprovalSyncFunction.ts │ │ └── waitForContractApprovalFunction.ts │ ├── schema │ │ └── unicorn_contracts │ │ │ └── contractstatuschanged │ │ │ ├── AWSEvent.ts │ │ │ ├── ContractStatusChanged.ts │ │ │ └── marshaller │ │ │ └── Marshaller.ts │ └── state_machine │ │ └── property_approval.asl.yaml ├── template.yaml ├── tests │ ├── events │ │ ├── dbb_stream_events │ │ │ ├── contract_status_changed_draft.json │ │ │ ├── sfn_check_exists.json │ │ │ ├── sfn_wait_approval.json │ │ │ ├── status_approved_waiting_for_approval.json │ │ │ └── status_approved_with_no_workflow.json │ │ ├── eventbridge │ │ │ ├── contract_status_changed_event_contract_1_approved.json │ │ │ ├── contract_status_changed_event_contract_1_draft.json │ │ │ ├── contract_status_changed_event_contract_2_approved.json │ │ │ ├── contract_status_changed_event_contract_2_draft.json │ │ │ ├── publication_approval_requested_event.json │ │ │ ├── publication_approval_requested_event_all_good.json │ │ │ ├── publication_approval_requested_event_inappropriate_description.json │ │ │ ├── publication_approval_requested_event_inappropriate_images.json │ │ │ ├── publication_approval_requested_event_non_existing_contract.json │ │ │ ├── publication_approval_requested_event_pause_workflow.json │ │ │ ├── publication_evaluation_completed_event.json │ │ │ └── put_event_property_approval_requested.json │ │ └── lambda │ │ │ ├── content_integrity_validator_function_success.json │ │ │ ├── contract_status_changed.json │ │ │ ├── contract_status_checker.json │ │ │ └── wait_for_contract_approval_function.json │ ├── integration │ │ ├── approval_workflow.test.ts │ │ ├── contract_status_changed_event.test.ts │ │ └── helper.ts │ └── unit │ │ ├── contractStatusChangedEventHandler.test.ts │ │ ├── propertiesApprovalSyncFunction.test.ts │ │ └── waitForContractApprovalFunction.test.ts └── tsconfig.json ├── unicorn_shared ├── Makefile ├── uni-prop-images.yaml └── uni-prop-namespaces.yaml └── unicorn_web ├── .gitignore ├── .npmignore ├── .prettierignore ├── .prettierrc.js ├── Makefile ├── README.md ├── api.yaml ├── data ├── approved_property.json ├── load_data.sh └── property_data.json ├── eslint.config.mjs ├── integration ├── PublicationApprovalRequested.json ├── event-schemas.yaml ├── subscriber-policies.yaml └── subscriptions.yaml ├── jest.config.js ├── package.json ├── samconfig.toml ├── src ├── approvals_service │ ├── powertools.ts │ ├── publicationApprovedEventHandler.ts │ └── requestApprovalFunction.ts ├── schema │ └── unicorn_properties │ │ └── publicationevaluationcompleted │ │ ├── AWSEvent.ts │ │ ├── PublicationEvaluationCompleted.ts │ │ └── marshaller │ │ └── Marshaller.ts └── search_service │ ├── powertools.ts │ └── propertySearchFunction.ts ├── template.yaml ├── tests ├── events │ └── eventbridge │ │ └── put_event_publication_evaluation_completed.json └── integration │ ├── helper.ts │ ├── requestApproval.test.ts │ ├── search.test.ts │ └── transformations │ └── ddb_contract.jq └── tsconfig.json /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners 2 | 3 | * @aws-samples/aws-serverless-developer-experience-workshop-ts -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: Bug report 2 | description: Report a reproducible bug to help us improve 3 | title: "Bug: TITLE" 4 | labels: ["bug", "triage"] 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: | 9 | Thank you for submitting a bug report. Please add as much information as possible to help us reproduce, and remove any potential sensitive data. 10 | - type: textarea 11 | id: expected_behaviour 12 | attributes: 13 | label: Expected Behaviour 14 | description: Please share details on the behaviour you expected 15 | validations: 16 | required: true 17 | - type: textarea 18 | id: current_behaviour 19 | attributes: 20 | label: Current Behaviour 21 | description: Please share details on the current issue 22 | validations: 23 | required: true 24 | - type: textarea 25 | id: code_snippet 26 | attributes: 27 | label: Code snippet 28 | description: Please share a code snippet to help us reproduce the issue 29 | render: csharp 30 | validations: 31 | required: true 32 | - type: textarea 33 | id: solution 34 | attributes: 35 | label: Possible Solution 36 | description: If known, please suggest a potential resolution 37 | validations: 38 | required: false 39 | - type: textarea 40 | id: steps 41 | attributes: 42 | label: Steps to Reproduce 43 | description: Please share how we might be able to reproduce this issue 44 | validations: 45 | required: true 46 | - type: textarea 47 | id: logs 48 | attributes: 49 | label: Debugging logs 50 | description: If available, please share debugging logs 51 | render: csharp 52 | validations: 53 | required: false 54 | - type: markdown 55 | attributes: 56 | value: | 57 | -- 58 | **Disclaimer**: We value your time and bandwidth. As such, any pull requests created on non-triaged issues might not be successful. 59 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/maintenance.yml: -------------------------------------------------------------------------------- 1 | name: Maintenance 2 | description: Suggest an activity to help address tech debt, governance, and anything internal 3 | title: "Maintenance: TITLE" 4 | labels: ["internal", "triage"] 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: | 9 | Thank you for taking the time to help us improve operational excellence. 10 | 11 | *Future readers*: Please react with 👍 and your use case to help us understand customer demand. 12 | - type: textarea 13 | id: activity 14 | attributes: 15 | label: Summary 16 | description: Please provide an overview in one or two paragraphs 17 | validations: 18 | required: true 19 | - type: textarea 20 | id: importance 21 | attributes: 22 | label: Why is this needed? 23 | description: Please help us understand the value so we can prioritize it accordingly 24 | validations: 25 | required: true 26 | - type: textarea 27 | id: suggestion 28 | attributes: 29 | label: Solution 30 | description: If available, please share what a good solution would look like 31 | validations: 32 | required: false 33 | - type: markdown 34 | attributes: 35 | value: | 36 | --- 37 | **Disclaimer**: We value your time and bandwidth. As such, any pull requests created on non-triaged issues might not be successful. 38 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | **Issue number:** 2 | 3 | ## Summary 4 | 5 | ### Changes 6 | 7 | > Please provide a summary of what's being changed 8 | 9 | ### User experience 10 | 11 | > Please share what the user experience looks like before and after this change 12 | 13 | ## Checklist 14 | 15 | Please leave checklist items unchecked if they do not apply to your change. 16 | 17 | * [ ] I have performed a self-review of this change 18 | * [ ] Changes have been tested 19 | * [ ] Changes are documented 20 | * [ ] PR title follows [conventional commit semantics](https://github.com/aws-samples/aws-serverless-developer-experience-workshop-typescript/blob/develop/.github/semantic.yml) 21 | 22 | ## Acknowledgment 23 | 24 | By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice. 25 | 26 | **Disclaimer**: We value your time and bandwidth. As such, any pull requests created on non-triaged issues might not be successful. 27 | -------------------------------------------------------------------------------- /.github/auto_assign.yml: -------------------------------------------------------------------------------- 1 | # Set to true to add reviewers to pull requests 2 | addReviewers: true 3 | 4 | # Set to true to add assignees to pull requests 5 | addAssignees: true 6 | 7 | # A list of reviewers to be added to pull requests (GitHub user name) 8 | reviewers: 9 | - sliedig 10 | 11 | # A number of reviewers added to the pull request 12 | # Set 0 to add all the reviewers (default: 0) 13 | numberOfReviewers: 1 14 | 15 | # A list of assignees, overrides reviewers if set 16 | # assignees: 17 | # - sliedig 18 | 19 | # A number of assignees to add to the pull request 20 | # Set to 0 to add all of the assignees. 21 | # Uses numberOfReviewers if unset. 22 | numberOfAssignees: 0 23 | 24 | # A list of keywords to be skipped the process that add reviewers if pull requests include it 25 | # skipKeywords: 26 | # - wip 27 | -------------------------------------------------------------------------------- /.github/boring-cyborg.yml: -------------------------------------------------------------------------------- 1 | ##### Labeler ########################################################################################################## 2 | labelPRBasedOnFilePath: 3 | service/contracts: 4 | - Unicorn.Contracts 5 | service/properties: 6 | - Unicorn.Properties 7 | service/web: 8 | - Unicorn.Web 9 | 10 | internal: 11 | - .github/* 12 | - .github/**/* 13 | - .chglog/* 14 | - .flake8 15 | - .gitignore 16 | - .pre-commit-config.yaml 17 | - Makefile 18 | - CONTRIBUTING.md 19 | - CODE_OF_CONDUCT.md 20 | - LICENSE 21 | 22 | ##### Greetings ######################################################################################################## 23 | firstPRWelcomeComment: > 24 | Thanks a lot for your first contribution! Please check out our contributing guidelines and don't hesitate to ask whatever you need. 25 | 26 | # Comment to be posted to congratulate user on their first merged PR 27 | firstPRMergeComment: > 28 | Awesome work, congrats on your first merged pull request and thank you for helping improve everyone's experience! 29 | 30 | # Comment to be posted to on first time issues 31 | firstIssueWelcomeComment: > 32 | Thanks for opening your first issue here! We'll come back to you as soon as we can. -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "npm" # See documentation for possible values 9 | directories: 10 | - "unicorn_contracts" # Location of package manifests 11 | - "unicorn_properties" 12 | - "unicorn_web" 13 | schedule: 14 | interval: "monthly" 15 | -------------------------------------------------------------------------------- /.github/scripts/comment_on_large_pr.js: -------------------------------------------------------------------------------- 1 | const { 2 | PR_NUMBER, 3 | PR_ACTION, 4 | PR_AUTHOR, 5 | IGNORE_AUTHORS, 6 | } = require("./constants") 7 | 8 | 9 | /** 10 | * Notify PR author to split XXL PR in smaller chunks 11 | * 12 | * @param {object} core - core functions instance from @actions/core 13 | * @param {object} gh_client - Pre-authenticated REST client (Octokit) 14 | * @param {string} owner - GitHub Organization 15 | * @param {string} repository - GitHub repository 16 | */ 17 | const notifyAuthor = async ({ 18 | core, 19 | gh_client, 20 | owner, 21 | repository, 22 | }) => { 23 | core.info(`Commenting on PR ${PR_NUMBER}`) 24 | 25 | let msg = `### ⚠️Large PR detected⚠️ 26 | 27 | Please consider breaking into smaller PRs to avoid significant review delays. Ignore if this PR has naturally grown to this size after reviews. 28 | `; 29 | 30 | try { 31 | await gh_client.rest.issues.createComment({ 32 | owner: owner, 33 | repo: repository, 34 | body: msg, 35 | issue_number: PR_NUMBER, 36 | }); 37 | } catch (error) { 38 | core.setFailed("Failed to notify PR author to split large PR"); 39 | console.error(err); 40 | } 41 | } 42 | 43 | module.exports = async ({github, context, core}) => { 44 | if (IGNORE_AUTHORS.includes(PR_AUTHOR)) { 45 | return core.notice("Author in IGNORE_AUTHORS list; skipping...") 46 | } 47 | 48 | if (PR_ACTION != "labeled") { 49 | return core.notice("Only run on PRs labeling actions; skipping") 50 | } 51 | 52 | 53 | /** @type {string[]} */ 54 | const { data: labels } = await github.rest.issues.listLabelsOnIssue({ 55 | owner: context.repo.owner, 56 | repo: context.repo.repo, 57 | issue_number: PR_NUMBER, 58 | }) 59 | 60 | // Schema: https://docs.github.com/en/rest/issues/labels#list-labels-for-an-issue 61 | for (const label of labels) { 62 | core.info(`Label: ${label}`) 63 | if (label.name == "size/XXL") { 64 | await notifyAuthor({ 65 | core: core, 66 | gh_client: github, 67 | owner: context.repo.owner, 68 | repository: context.repo.repo, 69 | }) 70 | break; 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /.github/scripts/constants.js: -------------------------------------------------------------------------------- 1 | module.exports = Object.freeze({ 2 | /** @type {string} */ 3 | // Values: https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#pull_request 4 | "PR_ACTION": process.env.PR_ACTION?.replace(/"/g, '') || "", 5 | 6 | /** @type {string} */ 7 | "PR_AUTHOR": process.env.PR_AUTHOR?.replace(/"/g, '') || "", 8 | 9 | /** @type {string} */ 10 | "PR_BODY": process.env.PR_BODY || "", 11 | 12 | /** @type {string} */ 13 | "PR_TITLE": process.env.PR_TITLE || "", 14 | 15 | /** @type {number} */ 16 | "PR_NUMBER": process.env.PR_NUMBER || 0, 17 | 18 | /** @type {string} */ 19 | "PR_IS_MERGED": process.env.PR_IS_MERGED || "false", 20 | 21 | /** @type {string} */ 22 | "LABEL_BLOCK": "do-not-merge", 23 | 24 | /** @type {string} */ 25 | "LABEL_BLOCK_REASON": "need-issue", 26 | 27 | /** @type {string} */ 28 | "LABEL_BLOCK_MISSING_LICENSE_AGREEMENT": "need-license-agreement-acknowledge", 29 | 30 | /** @type {string} */ 31 | "LABEL_PENDING_RELEASE": "pending-release", 32 | 33 | /** @type {string[]} */ 34 | "IGNORE_AUTHORS": ["dependabot[bot]", "markdownify[bot]"], 35 | 36 | }); 37 | -------------------------------------------------------------------------------- /.github/scripts/download_pr_artifact.js: -------------------------------------------------------------------------------- 1 | module.exports = async ({github, context, core}) => { 2 | const fs = require('fs'); 3 | 4 | const workflowRunId = process.env.WORKFLOW_ID; 5 | core.info(`Listing artifacts for workflow run ${workflowRunId}`); 6 | 7 | const artifacts = await github.rest.actions.listWorkflowRunArtifacts({ 8 | owner: context.repo.owner, 9 | repo: context.repo.repo, 10 | run_id: workflowRunId, 11 | }); 12 | 13 | const matchArtifact = artifacts.data.artifacts.filter(artifact => artifact.name == "pr")[0]; 14 | 15 | core.info(`Downloading artifacts for workflow run ${workflowRunId}`); 16 | const artifact = await github.rest.actions.downloadArtifact({ 17 | owner: context.repo.owner, 18 | repo: context.repo.repo, 19 | artifact_id: matchArtifact.id, 20 | archive_format: 'zip', 21 | }); 22 | 23 | core.info("Saving artifact found", artifact); 24 | 25 | fs.writeFileSync('pr.zip', Buffer.from(artifact.data)); 26 | } 27 | -------------------------------------------------------------------------------- /.github/scripts/enforce_acknowledgment.js: -------------------------------------------------------------------------------- 1 | const { 2 | PR_ACTION, 3 | PR_AUTHOR, 4 | PR_BODY, 5 | PR_NUMBER, 6 | IGNORE_AUTHORS, 7 | LABEL_BLOCK, 8 | LABEL_BLOCK_REASON 9 | } = require("./constants") 10 | 11 | module.exports = async ({github, context, core}) => { 12 | if (IGNORE_AUTHORS.includes(PR_AUTHOR)) { 13 | return core.notice("Author in IGNORE_AUTHORS list; skipping...") 14 | } 15 | 16 | if (PR_ACTION != "opened") { 17 | return core.notice("Only newly open PRs are labelled to avoid spam; skipping") 18 | } 19 | 20 | const RELATED_ISSUE_REGEX = /Issue number:[^\d\r\n]+(?\d+)/; 21 | const isMatch = RELATED_ISSUE_REGEX.exec(PR_BODY); 22 | if (isMatch == null) { 23 | core.info(`No related issue found, maybe the author didn't use the template but there is one.`) 24 | 25 | let msg = "No related issues found. Please ensure there is an open issue related to this change to avoid significant delays or closure."; 26 | await github.rest.issues.createComment({ 27 | owner: context.repo.owner, 28 | repo: context.repo.repo, 29 | body: msg, 30 | issue_number: PR_NUMBER, 31 | }); 32 | 33 | return await github.rest.issues.addLabels({ 34 | issue_number: PR_NUMBER, 35 | owner: context.repo.owner, 36 | repo: context.repo.repo, 37 | labels: [LABEL_BLOCK, LABEL_BLOCK_REASON] 38 | }) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /.github/scripts/label_missing_acknowledgement_section.js: -------------------------------------------------------------------------------- 1 | const { 2 | PR_ACTION, 3 | PR_AUTHOR, 4 | PR_BODY, 5 | PR_NUMBER, 6 | IGNORE_AUTHORS, 7 | LABEL_BLOCK, 8 | LABEL_BLOCK_MISSING_LICENSE_AGREEMENT 9 | } = require("./constants") 10 | 11 | module.exports = async ({github, context, core}) => { 12 | if (IGNORE_AUTHORS.includes(PR_AUTHOR)) { 13 | return core.notice("Author in IGNORE_AUTHORS list; skipping...") 14 | } 15 | 16 | if (PR_ACTION != "opened") { 17 | return core.notice("Only newly open PRs are labelled to avoid spam; skipping") 18 | } 19 | 20 | const RELATED_ACK_SECTION_REGEX = /By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice./; 21 | 22 | const isMatch = RELATED_ACK_SECTION_REGEX.exec(PR_BODY); 23 | if (isMatch == null) { 24 | core.info(`No acknowledgement section found, maybe the author didn't use the template but there is one.`) 25 | 26 | let msg = "No acknowledgement section found. Please make sure you used the template to open a PR and didn't remove the acknowledgment section. Check the template here: https://github.com/aws-samples/aws-serverless-developer-experience-workshop-typescript/blob/develop/.github/PULL_REQUEST_TEMPLATE.md#acknowledgment"; 27 | await github.rest.issues.createComment({ 28 | owner: context.repo.owner, 29 | repo: context.repo.repo, 30 | body: msg, 31 | issue_number: PR_NUMBER, 32 | }); 33 | 34 | return await github.rest.issues.addLabels({ 35 | issue_number: PR_NUMBER, 36 | owner: context.repo.owner, 37 | repo: context.repo.repo, 38 | labels: [LABEL_BLOCK, LABEL_BLOCK_MISSING_LICENSE_AGREEMENT] 39 | }) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /.github/scripts/label_missing_related_issue.js: -------------------------------------------------------------------------------- 1 | const { 2 | PR_ACTION, 3 | PR_AUTHOR, 4 | PR_BODY, 5 | PR_NUMBER, 6 | IGNORE_AUTHORS, 7 | LABEL_BLOCK, 8 | LABEL_BLOCK_REASON 9 | } = require("./constants") 10 | 11 | module.exports = async ({github, context, core}) => { 12 | if (IGNORE_AUTHORS.includes(PR_AUTHOR)) { 13 | return core.notice("Author in IGNORE_AUTHORS list; skipping...") 14 | } 15 | 16 | if (PR_ACTION != "opened") { 17 | return core.notice("Only newly open PRs are labelled to avoid spam; skipping") 18 | } 19 | 20 | const RELATED_ISSUE_REGEX = /Issue number:[^\d\r\n]+(?\d+)/; 21 | const isMatch = RELATED_ISSUE_REGEX.exec(PR_BODY); 22 | if (isMatch == null) { 23 | core.info(`No related issue found, maybe the author didn't use the template but there is one.`) 24 | 25 | let msg = "No related issues found. Please ensure there is an open issue related to this change to avoid significant delays or closure."; 26 | await github.rest.issues.createComment({ 27 | owner: context.repo.owner, 28 | repo: context.repo.repo, 29 | body: msg, 30 | issue_number: PR_NUMBER, 31 | }); 32 | 33 | return await github.rest.issues.addLabels({ 34 | issue_number: PR_NUMBER, 35 | owner: context.repo.owner, 36 | repo: context.repo.repo, 37 | labels: [LABEL_BLOCK, LABEL_BLOCK_REASON] 38 | }) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /.github/scripts/label_pr_based_on_title.js: -------------------------------------------------------------------------------- 1 | const { PR_NUMBER, PR_TITLE } = require("./constants") 2 | 3 | module.exports = async ({github, context, core}) => { 4 | const FEAT_REGEX = /feat(\((.+)\))?(:.+)/ 5 | const BUG_REGEX = /(fix|bug)(\((.+)\))?(:.+)/ 6 | const DOCS_REGEX = /(docs|doc)(\((.+)\))?(:.+)/ 7 | const CHORE_REGEX = /(chore)(\((.+)\))?(:.+)/ 8 | const DEPRECATED_REGEX = /(deprecated)(\((.+)\))?(:.+)/ 9 | const REFACTOR_REGEX = /(refactor)(\((.+)\))?(:.+)/ 10 | 11 | const labels = { 12 | "feature": FEAT_REGEX, 13 | "bug": BUG_REGEX, 14 | "documentation": DOCS_REGEX, 15 | "internal": CHORE_REGEX, 16 | "enhancement": REFACTOR_REGEX, 17 | "deprecated": DEPRECATED_REGEX, 18 | } 19 | 20 | // Maintenance: We should keep track of modified PRs in case their titles change 21 | let miss = 0; 22 | try { 23 | for (const label in labels) { 24 | const matcher = new RegExp(labels[label]) 25 | const matches = matcher.exec(PR_TITLE) 26 | if (matches != null) { 27 | core.info(`Auto-labeling PR ${PR_NUMBER} with ${label}`) 28 | 29 | await github.rest.issues.addLabels({ 30 | issue_number: PR_NUMBER, 31 | owner: context.repo.owner, 32 | repo: context.repo.repo, 33 | labels: [label] 34 | }) 35 | 36 | return; 37 | } else { 38 | core.debug(`'${PR_TITLE}' didn't match '${label}' semantic.`) 39 | miss += 1 40 | } 41 | } 42 | } finally { 43 | if (miss == Object.keys(labels).length) { 44 | core.notice(`PR ${PR_NUMBER} title '${PR_TITLE}' doesn't follow semantic titles; skipping...`) 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /.github/scripts/label_related_issue.js: -------------------------------------------------------------------------------- 1 | const { 2 | PR_AUTHOR, 3 | PR_BODY, 4 | PR_NUMBER, 5 | IGNORE_AUTHORS, 6 | LABEL_PENDING_RELEASE, 7 | HANDLE_MAINTAINERS_TEAM, 8 | PR_IS_MERGED, 9 | } = require("./constants") 10 | 11 | module.exports = async ({github, context, core}) => { 12 | if (IGNORE_AUTHORS.includes(PR_AUTHOR)) { 13 | return core.notice("Author in IGNORE_AUTHORS list; skipping...") 14 | } 15 | 16 | if (PR_IS_MERGED == "false") { 17 | return core.notice("Only merged PRs to avoid spam; skipping") 18 | } 19 | 20 | const RELATED_ISSUE_REGEX = /Issue number:[^\d\r\n]+(?\d+)/; 21 | 22 | const isMatch = RELATED_ISSUE_REGEX.exec(PR_BODY); 23 | 24 | try { 25 | if (!isMatch) { 26 | core.setFailed(`Unable to find related issue for PR number ${PR_NUMBER}.\n\n Body details: ${PR_BODY}`); 27 | return await github.rest.issues.createComment({ 28 | owner: context.repo.owner, 29 | repo: context.repo.repo, 30 | body: `${HANDLE_MAINTAINERS_TEAM} No related issues found. Please ensure '${LABEL_PENDING_RELEASE}' label is applied before releasing.`, 31 | issue_number: PR_NUMBER, 32 | }); 33 | } 34 | } catch (error) { 35 | core.setFailed(`Unable to create comment on PR number ${PR_NUMBER}.\n\n Error details: ${error}`); 36 | throw new Error(error); 37 | } 38 | 39 | const { groups: {issue} } = isMatch 40 | 41 | try { 42 | core.info(`Auto-labeling related issue ${issue} for release`) 43 | return await github.rest.issues.addLabels({ 44 | issue_number: issue, 45 | owner: context.repo.owner, 46 | repo: context.repo.repo, 47 | labels: [LABEL_PENDING_RELEASE] 48 | }) 49 | } catch (error) { 50 | core.setFailed(`Is this issue number (${issue}) valid? Perhaps a discussion?`); 51 | throw new Error(error); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /.github/scripts/save_pr_details.js: -------------------------------------------------------------------------------- 1 | module.exports = async ({context, core}) => { 2 | const fs = require('fs'); 3 | const filename = "pr.txt"; 4 | 5 | try { 6 | fs.writeFileSync(`./${filename}`, JSON.stringify(context.payload)); 7 | 8 | return `PR successfully saved ${filename}` 9 | } catch (err) { 10 | core.setFailed("Failed to save PR details"); 11 | console.error(err); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /.github/semantic.yml: -------------------------------------------------------------------------------- 1 | # conventional commit types: https://github.com/commitizen/conventional-commit-types/blob/v3.0.0/index.json 2 | types: 3 | - feat 4 | - fix 5 | - docs 6 | - style 7 | - refactor 8 | - perf 9 | - test 10 | - build 11 | - ci 12 | - chore 13 | - revert 14 | - improv 15 | 16 | # Always validate the PR title 17 | # and ignore the commits to lower the entry bar for contribution 18 | # while titles make up the Release notes to ease maintenance overhead 19 | titleOnly: true 20 | -------------------------------------------------------------------------------- /.github/workflows/auto_assign.yml: -------------------------------------------------------------------------------- 1 | name: 'Auto Assign' 2 | on: 3 | pull_request: 4 | types: [opened, ready_for_review] 5 | 6 | jobs: 7 | add-reviews: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: kentaro-m/auto-assign-action@v1.2.5 -------------------------------------------------------------------------------- /.github/workflows/build_test.yml: -------------------------------------------------------------------------------- 1 | name: Build & Test Workflow 2 | on: 3 | push: 4 | branches: [develop] 5 | paths: 6 | - '.github/workflows/*' 7 | - 'unicorn_shared/**' 8 | - 'unicorn_contracts/**' 9 | - 'unicorn_properties/**' 10 | - 'unicorn_web/**' 11 | env: 12 | AWS_REGION : "ap-southeast-2" 13 | 14 | permissions: 15 | id-token: write 16 | contents: read 17 | 18 | jobs: 19 | shared-infrastructure: 20 | runs-on: ubuntu-latest 21 | steps: 22 | - uses: actions/checkout@v4 23 | 24 | - name: Configure AWS credentials 25 | uses: aws-actions/configure-aws-credentials@v4 26 | with: 27 | role-to-assume: arn:aws:iam::819998446679:role/GithubActions-ServerlessDeveloperExperience 28 | aws-region: ${{ env.AWS_REGION }} 29 | 30 | - name: Deploy Shared Images 31 | working-directory: unicorn_shared 32 | run: make deploy-images 33 | 34 | - name: Deploy Shared Namespaces 35 | working-directory: unicorn_shared 36 | run: aws cloudformation update-stack --stack-name uni-prop-namespaces --template-body file://uni-prop-namespaces.yaml --capabilities CAPABILITY_AUTO_EXPAND 37 | 38 | uniorn-service: 39 | needs: shared-infrastructure 40 | runs-on: ubuntu-latest 41 | continue-on-error: true 42 | 43 | strategy: 44 | #max-parallel: 1 45 | matrix: 46 | folder: [unicorn_contracts, unicorn_web, unicorn_properties] 47 | 48 | steps: 49 | - uses: actions/checkout@v4 50 | 51 | - name: Setup NodeJS 52 | uses: actions/setup-node@v4 53 | with: 54 | node-version: 18.x 55 | 56 | - uses: pnpm/action-setup@v3 57 | with: 58 | version: 8 59 | package_json_file: ${{ matrix.folder }}/package.json 60 | 61 | - name: Install dependencies 62 | run: pnpm i 63 | working-directory: ${{ matrix.folder }} 64 | 65 | - name: Run unit tests 66 | run: make unit-test 67 | working-directory: ${{ matrix.folder }} 68 | 69 | - name: Configure AWS credentials 70 | uses: aws-actions/configure-aws-credentials@v4 71 | with: 72 | role-to-assume: arn:aws:iam::819998446679:role/GithubActions-ServerlessDeveloperExperience 73 | aws-region: ${{ env.AWS_REGION }} 74 | 75 | - name: Configure AWS SAM 76 | uses: aws-actions/setup-sam@v2 77 | with: 78 | use-installer: true 79 | 80 | - name: Build the SAM template 81 | run: sam build 82 | working-directory: ${{ matrix.folder }} 83 | 84 | - name: Deploy the SAM template 85 | run: sam deploy --no-confirm-changeset 86 | working-directory: ${{ matrix.folder }} 87 | 88 | - name: Run integration tests 89 | run: make integration-test 90 | working-directory: ${{ matrix.folder }} 91 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | name: "CodeQL" 2 | 3 | on: 4 | push: 5 | branches: [ "develop", main ] 6 | pull_request: 7 | # The branches below must be a subset of the branches above 8 | branches: [ "develop" ] 9 | schedule: 10 | - cron: '42 8 * * 0' 11 | 12 | jobs: 13 | analyze: 14 | name: Analyze 15 | runs-on: ubuntu-latest 16 | permissions: 17 | actions: read 18 | contents: read 19 | security-events: write 20 | 21 | strategy: 22 | fail-fast: false 23 | matrix: 24 | language: [ 'javascript' ] 25 | 26 | steps: 27 | - name: Checkout repository 28 | uses: actions/checkout@v3 29 | 30 | # Initializes the CodeQL tools for scanning. 31 | - name: Initialize CodeQL 32 | uses: github/codeql-action/init@2ca79b6fa8d3ec278944088b4aa5f46912db5d63 #v2 33 | with: 34 | languages: ${{ matrix.language }} 35 | 36 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 37 | # If this step fails, then you should remove it and run the build manually (see below) 38 | # - name: Autobuild 39 | # uses: github/codeql-action/autobuild@2ca79b6fa8d3ec278944088b4aa5f46912db5d63 #v2 40 | 41 | # ℹ️ Command-line programs to run using the OS shell. 42 | # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun 43 | 44 | # If the Autobuild fails above, remove it and uncomment the following three lines. 45 | # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. 46 | 47 | # - run: | 48 | # echo "Run, Build Application using script" 49 | # ./location_of_script_within_repo/buildscript.sh 50 | 51 | - name: Perform CodeQL Analysis 52 | uses: github/codeql-action/analyze@2ca79b6fa8d3ec278944088b4aa5f46912db5d63 #v2 53 | -------------------------------------------------------------------------------- /.github/workflows/label_pr_on_title.yml: -------------------------------------------------------------------------------- 1 | name: Label PR based on title 2 | 3 | on: 4 | workflow_run: 5 | workflows: ["Record PR details"] 6 | types: 7 | - completed 8 | 9 | jobs: 10 | get_pr_details: 11 | # Guardrails to only ever run if PR recording workflow was indeed 12 | # run in a PR event and ran successfully 13 | if: ${{ github.event.workflow_run.conclusion == 'success' }} 14 | uses: ./.github/workflows/reusable_export_pr_details.yml 15 | with: 16 | record_pr_workflow_id: ${{ github.event.workflow_run.id }} 17 | workflow_origin: ${{ github.event.repository.full_name }} 18 | secrets: 19 | token: ${{ secrets.GITHUB_TOKEN }} 20 | label_pr: 21 | needs: get_pr_details 22 | runs-on: ubuntu-latest 23 | steps: 24 | - name: Checkout repository 25 | uses: actions/checkout@v3 26 | - name: "Label PR based on title" 27 | uses: actions/github-script@v6 28 | env: 29 | PR_NUMBER: ${{ needs.get_pr_details.outputs.prNumber }} 30 | PR_TITLE: ${{ needs.get_pr_details.outputs.prTitle }} 31 | with: 32 | github-token: ${{ secrets.GITHUB_TOKEN }} 33 | # This safely runs in our base repo, not on fork 34 | # thus allowing us to provide a write access token to label based on PR title 35 | # and label PR based on semantic title accordingly 36 | script: | 37 | const script = require('.github/scripts/label_pr_based_on_title.js') 38 | await script({github, context, core}) 39 | -------------------------------------------------------------------------------- /.github/workflows/on_label_added.yml: -------------------------------------------------------------------------------- 1 | name: On Label added 2 | 3 | on: 4 | workflow_run: 5 | workflows: ["Record PR details"] 6 | types: 7 | - completed 8 | 9 | jobs: 10 | get_pr_details: 11 | if: ${{ github.event.workflow_run.conclusion == 'success' }} 12 | uses: ./.github/workflows/reusable_export_pr_details.yml 13 | with: 14 | record_pr_workflow_id: ${{ github.event.workflow_run.id }} 15 | workflow_origin: ${{ github.event.repository.full_name }} 16 | secrets: 17 | token: ${{ secrets.GITHUB_TOKEN }} 18 | 19 | split-large-pr: 20 | needs: get_pr_details 21 | runs-on: ubuntu-latest 22 | permissions: 23 | issues: write 24 | pull-requests: write 25 | steps: 26 | - uses: actions/checkout@v3 27 | # Maintenance: Persist state per PR as an artifact to avoid spam on label add 28 | - name: "Suggest split large Pull Request" 29 | uses: actions/github-script@v6 30 | env: 31 | PR_NUMBER: ${{ needs.get_pr_details.outputs.prNumber }} 32 | PR_ACTION: ${{ needs.get_pr_details.outputs.prAction }} 33 | PR_AUTHOR: ${{ needs.get_pr_details.outputs.prAuthor }} 34 | with: 35 | github-token: ${{ secrets.GITHUB_TOKEN }} 36 | script: | 37 | const script = require('.github/scripts/comment_on_large_pr.js'); 38 | await script({github, context, core}); 39 | -------------------------------------------------------------------------------- /.github/workflows/on_merged_pr.yml: -------------------------------------------------------------------------------- 1 | name: On PR merge 2 | 3 | on: 4 | workflow_run: 5 | workflows: ["Record PR details"] 6 | types: 7 | - completed 8 | 9 | jobs: 10 | get_pr_details: 11 | if: github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success' 12 | uses: ./.github/workflows/reusable_export_pr_details.yml 13 | with: 14 | record_pr_workflow_id: ${{ github.event.workflow_run.id }} 15 | workflow_origin: ${{ github.event.repository.full_name }} 16 | secrets: 17 | token: ${{ secrets.GITHUB_TOKEN }} 18 | release_label_on_merge: 19 | needs: get_pr_details 20 | runs-on: ubuntu-latest 21 | if: needs.get_pr_details.outputs.prIsMerged == 'true' 22 | steps: 23 | - uses: actions/checkout@v3 24 | - name: "Label PR related issue for release" 25 | uses: actions/github-script@v6 26 | env: 27 | PR_NUMBER: ${{ needs.get_pr_details.outputs.prNumber }} 28 | PR_BODY: ${{ needs.get_pr_details.outputs.prBody }} 29 | PR_IS_MERGED: ${{ needs.get_pr_details.outputs.prIsMerged }} 30 | PR_AUTHOR: ${{ needs.get_pr_details.outputs.prAuthor }} 31 | with: 32 | github-token: ${{ secrets.GITHUB_TOKEN }} 33 | script: | 34 | const script = require('.github/scripts/label_related_issue.js') 35 | await script({github, context, core}) 36 | -------------------------------------------------------------------------------- /.github/workflows/on_opened_pr.yml: -------------------------------------------------------------------------------- 1 | name: On new PR 2 | 3 | on: 4 | workflow_run: 5 | workflows: ["Record PR details"] 6 | types: 7 | - completed 8 | 9 | jobs: 10 | get_pr_details: 11 | if: ${{ github.event.workflow_run.conclusion == 'success' }} 12 | uses: ./.github/workflows/reusable_export_pr_details.yml 13 | with: 14 | record_pr_workflow_id: ${{ github.event.workflow_run.id }} 15 | workflow_origin: ${{ github.event.repository.full_name }} 16 | secrets: 17 | token: ${{ secrets.GITHUB_TOKEN }} 18 | check_related_issue: 19 | needs: get_pr_details 20 | runs-on: ubuntu-latest 21 | steps: 22 | - uses: actions/checkout@v3 23 | - name: "Ensure related issue is present" 24 | uses: actions/github-script@v6 25 | env: 26 | PR_BODY: ${{ needs.get_pr_details.outputs.prBody }} 27 | PR_NUMBER: ${{ needs.get_pr_details.outputs.prNumber }} 28 | PR_ACTION: ${{ needs.get_pr_details.outputs.prAction }} 29 | PR_AUTHOR: ${{ needs.get_pr_details.outputs.prAuthor }} 30 | with: 31 | github-token: ${{ secrets.GITHUB_TOKEN }} 32 | script: | 33 | const script = require('.github/scripts/label_missing_related_issue.js') 34 | await script({github, context, core}) 35 | check_acknowledge_section: 36 | needs: get_pr_details 37 | runs-on: ubuntu-latest 38 | steps: 39 | - uses: actions/checkout@v3 40 | - name: "Ensure acknowledgement section is present" 41 | uses: actions/github-script@v6 42 | env: 43 | PR_BODY: ${{ needs.get_pr_details.outputs.prBody }} 44 | PR_NUMBER: ${{ needs.get_pr_details.outputs.prNumber }} 45 | PR_ACTION: ${{ needs.get_pr_details.outputs.prAction }} 46 | PR_AUTHOR: ${{ needs.get_pr_details.outputs.prAuthor }} 47 | with: 48 | github-token: ${{ secrets.GITHUB_TOKEN }} 49 | script: | 50 | const script = require('.github/scripts/label_missing_acknowledgement_section.js') 51 | await script({github, context, core}) 52 | -------------------------------------------------------------------------------- /.github/workflows/record_pr.yml: -------------------------------------------------------------------------------- 1 | name: Record PR details 2 | 3 | on: 4 | pull_request: 5 | types: [opened, edited, closed] 6 | 7 | jobs: 8 | record_pr: 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | - uses: actions/checkout@v3 13 | - name: "Extract PR details" 14 | uses: actions/github-script@v6 15 | with: 16 | script: | 17 | const script = require('.github/scripts/save_pr_details.js') 18 | await script({github, context, core}) 19 | - uses: actions/upload-artifact@v4 20 | with: 21 | name: pr 22 | path: pr.txt 23 | -------------------------------------------------------------------------------- /.github/workflows/reusable_export_pr_details.yml: -------------------------------------------------------------------------------- 1 | name: Export previously recorded PR 2 | 3 | on: 4 | workflow_call: 5 | inputs: 6 | record_pr_workflow_id: 7 | description: "Record PR workflow execution ID to download PR details" 8 | required: true 9 | type: number 10 | workflow_origin: 11 | description: "Repository full name for runner integrity" 12 | required: true 13 | type: string 14 | secrets: 15 | token: 16 | description: "GitHub Actions temporary and scoped token" 17 | required: true 18 | # Map the workflow outputs to job outputs 19 | outputs: 20 | prNumber: 21 | description: "PR Number" 22 | value: ${{ jobs.export_pr_details.outputs.prNumber }} 23 | prTitle: 24 | description: "PR Title" 25 | value: ${{ jobs.export_pr_details.outputs.prTitle }} 26 | prBody: 27 | description: "PR Body as string" 28 | value: ${{ jobs.export_pr_details.outputs.prBody }} 29 | prAuthor: 30 | description: "PR author username" 31 | value: ${{ jobs.export_pr_details.outputs.prAuthor }} 32 | prAction: 33 | description: "PR event action" 34 | value: ${{ jobs.export_pr_details.outputs.prAction }} 35 | prIsMerged: 36 | description: "Whether PR is merged" 37 | value: ${{ jobs.export_pr_details.outputs.prIsMerged }} 38 | 39 | jobs: 40 | export_pr_details: 41 | 42 | if: inputs.workflow_origin == 'aws-samples/aws-serverless-developer-experience-workshop-typescript' 43 | runs-on: ubuntu-latest 44 | env: 45 | FILENAME: pr.txt 46 | # Map the job outputs to step outputs 47 | outputs: 48 | prNumber: ${{ steps.prNumber.outputs.prNumber }} 49 | prTitle: ${{ steps.prTitle.outputs.prTitle }} 50 | prBody: ${{ steps.prBody.outputs.prBody }} 51 | prAuthor: ${{ steps.prAuthor.outputs.prAuthor }} 52 | prAction: ${{ steps.prAction.outputs.prAction }} 53 | prIsMerged: ${{ steps.prIsMerged.outputs.prIsMerged }} 54 | steps: 55 | - name: Checkout repository # in case caller workflow doesn't checkout thus failing with file not found 56 | uses: actions/checkout@v3 57 | - name: "Download previously saved PR" 58 | uses: actions/github-script@v6 59 | env: 60 | WORKFLOW_ID: ${{ inputs.record_pr_workflow_id }} 61 | # For security, we only download artifacts tied to the successful PR recording workflow 62 | with: 63 | github-token: ${{ secrets.token }} 64 | script: | 65 | const script = require('.github/scripts/download_pr_artifact.js') 66 | await script({github, context, core}) 67 | # NodeJS standard library doesn't provide ZIP capabilities; use system `unzip` command instead 68 | - name: "Unzip PR artifact" 69 | run: unzip pr.zip 70 | # NOTE: We need separate steps for each mapped output and respective IDs 71 | # otherwise the parent caller won't see them regardless on how outputs are set. 72 | - name: "Export Pull Request Number" 73 | id: prNumber 74 | run: echo prNumber=$(jq -c '.number' ${FILENAME}) >> "$GITHUB_OUTPUT" 75 | - name: "Export Pull Request Title" 76 | id: prTitle 77 | run: echo prTitle=$(jq -c '.pull_request.title' ${FILENAME}) >> "$GITHUB_OUTPUT" 78 | - name: "Export Pull Request Body" 79 | id: prBody 80 | run: echo prBody=$(jq -c '.pull_request.body' ${FILENAME}) >> "$GITHUB_OUTPUT" 81 | - name: "Export Pull Request Author" 82 | id: prAuthor 83 | run: echo prAuthor=$(jq -c '.pull_request.user.login' ${FILENAME}) >> "$GITHUB_OUTPUT" 84 | - name: "Export Pull Request Action" 85 | id: prAction 86 | run: echo prAction=$(jq -c '.action' ${FILENAME}) >> "$GITHUB_OUTPUT" 87 | - name: "Export Pull Request Merged status" 88 | id: prIsMerged 89 | run: echo prIsMerged=$(jq -c '.pull_request.merged' ${FILENAME}) >> "$GITHUB_OUTPUT" 90 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ### CDK-specific ignores ### 2 | *.swp 3 | cdk.context.json 4 | package-lock.json 5 | yarn.lock 6 | .cdk.staging 7 | cdk.out 8 | 9 | # Created by https://www.toptal.com/developers/gitignore/api/node 10 | # Edit at https://www.toptal.com/developers/gitignore?templates=node 11 | 12 | ### Node ### 13 | # Logs 14 | logs 15 | *.log 16 | npm-debug.log* 17 | yarn-debug.log* 18 | yarn-error.log* 19 | lerna-debug.log* 20 | .pnpm-debug.log* 21 | 22 | # Diagnostic reports (https://nodejs.org/api/report.html) 23 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 24 | 25 | # Runtime data 26 | pids 27 | *.pid 28 | *.seed 29 | *.pid.lock 30 | 31 | # Directory for instrumented libs generated by jscoverage/JSCover 32 | lib-cov 33 | 34 | # Coverage directory used by tools like istanbul 35 | coverage 36 | *.lcov 37 | 38 | # nyc test coverage 39 | .nyc_output 40 | 41 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 42 | .grunt 43 | 44 | # Bower dependency directory (https://bower.io/) 45 | bower_components 46 | 47 | # node-waf configuration 48 | .lock-wscript 49 | 50 | # Compiled binary addons (https://nodejs.org/api/addons.html) 51 | build/Release 52 | 53 | # Dependency directories 54 | node_modules/ 55 | jspm_packages/ 56 | 57 | # Snowpack dependency directory (https://snowpack.dev/) 58 | web_modules/ 59 | 60 | # TypeScript cache 61 | *.tsbuildinfo 62 | 63 | # Optional npm cache directory 64 | .npm 65 | 66 | # Optional eslint cache 67 | .eslintcache 68 | 69 | # Optional stylelint cache 70 | .stylelintcache 71 | 72 | # Microbundle cache 73 | .rpt2_cache/ 74 | .rts2_cache_cjs/ 75 | .rts2_cache_es/ 76 | .rts2_cache_umd/ 77 | 78 | # Optional REPL history 79 | .node_repl_history 80 | 81 | # Output of 'npm pack' 82 | *.tgz 83 | 84 | # Yarn Integrity file 85 | .yarn-integrity 86 | 87 | # dotenv environment variable files 88 | .env 89 | .env.development.local 90 | .env.test.local 91 | .env.production.local 92 | .env.local 93 | 94 | # parcel-bundler cache (https://parceljs.org/) 95 | .cache 96 | .parcel-cache 97 | 98 | # Next.js build output 99 | .next 100 | out 101 | 102 | # Nuxt.js build / generate output 103 | .nuxt 104 | dist 105 | 106 | # Gatsby files 107 | .cache/ 108 | # Comment in the public line in if your project uses Gatsby and not Next.js 109 | # https://nextjs.org/blog/next-9-1#public-directory-support 110 | # public 111 | 112 | # vuepress build output 113 | .vuepress/dist 114 | 115 | # vuepress v2.x temp and cache directory 116 | .temp 117 | 118 | # Docusaurus cache and generated files 119 | .docusaurus 120 | 121 | # Serverless directories 122 | .serverless/ 123 | 124 | # FuseBox cache 125 | .fusebox/ 126 | 127 | # DynamoDB Local files 128 | .dynamodb/ 129 | 130 | # TernJS port file 131 | .tern-port 132 | 133 | # Stores VSCode versions used for testing VSCode extensions 134 | .vscode-test 135 | 136 | # yarn v2 137 | .yarn/cache 138 | .yarn/unplugged 139 | .yarn/build-state.yml 140 | .yarn/install-state.gz 141 | .pnp.* 142 | 143 | ### Node Patch ### 144 | # Serverless Webpack directories 145 | .webpack/ 146 | 147 | # Optional stylelint cache 148 | 149 | # SvelteKit build / generate output 150 | .svelte-kit 151 | 152 | # End of https://www.toptal.com/developers/gitignore/api/node 153 | 154 | ### Linux ### 155 | **/*~ 156 | **/.fuse_hidden* 157 | **/.directory 158 | **/.Trash-* 159 | **/.nfs* 160 | 161 | ### OSX ### 162 | **/*.DS_Store 163 | **/.AppleDouble 164 | **/.LSOverride 165 | **/.DocumentRevisions-V100 166 | **/.fseventsd 167 | **/.Spotlight-V100 168 | **/.TemporaryItems 169 | **/.Trashes 170 | **/.VolumeIcon.icns 171 | **/.com.apple.timemachine.donotpresent 172 | 173 | ### JetBrains IDEs ### 174 | **/*.iws 175 | **/.idea/ 176 | **/.idea_modules/ 177 | 178 | ### Python ### 179 | **/__pycache__/ 180 | **/*.py[cod] 181 | **/*$py.class 182 | **/.Python 183 | **/build/ 184 | **/develop-eggs/ 185 | **/dist/ 186 | **/downloads/ 187 | **/eggs/ 188 | **/.eggs/ 189 | **/parts/ 190 | **/sdist/ 191 | **/wheels/ 192 | **/*.egg 193 | **/*.egg-info/ 194 | **/.installed.cfg 195 | **/pip-log.txt 196 | **/pip-delete-this-directory.txt 197 | 198 | ### Unit test / coverage reports ### 199 | **/.cache 200 | **/.coverage 201 | **/.hypothesis/ 202 | **/.pytest_cache/ 203 | **/.tox/ 204 | **/*.cover 205 | **/coverage.xml 206 | **/htmlcov/ 207 | **/nosetests.xml 208 | 209 | ### pyenv ### 210 | **/.python-version 211 | 212 | ### Environments ### 213 | **/.env 214 | **/.venv 215 | **/env/ 216 | **/venv/ 217 | **/ENV/ 218 | **/env.bak/ 219 | **/venv.bak/ 220 | 221 | ### VisualStudioCode ### 222 | **/.vscode/ 223 | **/.history 224 | 225 | ### Windows ### 226 | # Windows thumbnail cache files 227 | **/Thumbs.db 228 | **/ehthumbs.db 229 | **/ehthumbs_vista.db 230 | **/Desktop.ini 231 | **/$RECYCLE.BIN/ 232 | **/*.lnk 233 | 234 | ### requirements.txt ### 235 | # We ignore Python's requirements.txt as we use Poetry instead 236 | **/requirements.txt 237 | **/.aws-sam -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/aws-serverless-developer-experience-workshop-typescript/689b13ea49a2f53fdcec06051d90a64b4902f320/.npmrc -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | **/template.yaml -------------------------------------------------------------------------------- /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, or recently closed, 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 *main* 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' 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](LICENSE) file for our project's licensing. We will ask you to confirm the licensing of your contribution. 60 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 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 | 16 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build & Test Workflow](https://github.com/aws-samples/aws-serverless-developer-experience-workshop-typescript/actions/workflows/build_test.yml/badge.svg)](https://github.com/aws-samples/aws-serverless-developer-experience-workshop-typescript/actions/workflows/build_test.yml) 2 | 3 | AWS Serverless Developer Experience Workshop Reference Architecture 4 | 5 | # AWS Serverless Developer Experience workshop reference architecture (TypeScript) 6 | 7 | This repository contains the reference architecture for the AWS Serverless Developer Experience workshop. 8 | 9 | The AWS Serverless Developer Experience workshop provides you with an immersive experience as a serverless developer. The goal of this workshop is to provide you with hands-on experience building a serverless solution using the [**AWS Serverless Application Model (AWS SAM)**](https://aws.amazon.com/serverless/sam/) and **AWS SAM CLI**. 10 | 11 | Along the way, you will learn about principals of distributed event-driven architectures, messaging patterns, orchestration, and observability and how to apply them in code. You will explore exciting open-source tools, the core features of Powertools for AWS Lambda, and simplified CI/CD deployments supported by AWS SAM Pipelines. 12 | 13 | At the end of this workshop, you will be familiar with Serverless developer workflows and microservice composition using AWS SAM, Serverless development best practices, and applied event-driven architectures. 14 | 15 | ## Introducing the Unicorn Properties architecture 16 | 17 | ![AWS Serverless Developer Experience Workshop Reference Architecture](./docs/architecture.png) 18 | 19 | Our use case is based on a real estate company called **Unicorn Properties**. 20 | 21 | As a real estate agency, **Unicorn Properties** needs to manage the publishing of new property listings and sale contracts linked to individual properties, and provide a way for their customers to view approved property listings. 22 | 23 | To support their needs, Unicorn Properties have adopted a serverless, event-driven approach to designing their architecture. This architecture is centred around two primary domains: **Contracts** (managed by the Contracts Service) and **Properties** (managed by the Web and Properties Services). 24 | 25 | The **Unicorn Contracts** service (namespace: `Unicorn.Contracts`) is a simplified service that manages the contractual relationship between a seller of a property and Unicorn Properties. Contracts are drawn up that define the property for sale, the terms and conditions that Unicorn Properties sets, and how much it will cost the seller to engage the services of the agency. 26 | 27 | The **Unicorn Web** (namespace: `Unicorn.Web`) manages the details of a property listing to be published on the Unicorn Properties website. Every property listing has an address, a sale price, a description of the property, and some photos that members of the public can look at to get them interested in purchasing the property. Only properties that have been approved for publication can be made visible to the public. 28 | 29 | The **Unicorn Properties** service (namespace: `Unicorn.Properties`) approves a property listings. This service implements a workflow that checks for the existence of a contract, makes sure that the content and the images are safe to publish, and finally checks that the contract has been approved. We don’t want to publish a property until we have an approved contract! 30 | 31 | Have a go at building this architecture yourself! Head over to the [Serverless Developer Experience Workshop](https://catalog.workshops.aws/serverless-developer-experience) for more details. 32 | ## Credits 33 | 34 | Throughout this workshop we wanted to introduce you to some Open Source tools that can help you build serverless applications. This is not an exhaustive list, just a small selection of what we will be using in the workshop. 35 | 36 | Many thanks to all the AWS teams and community builders who have contributed to this list: 37 | 38 | | Tools | Description | Download / Installation Instructions | 39 | | --------------------- | ----------- | --------------------------------------- | 40 | | cfn-lint | Validate AWS CloudFormation yaml/json templates against the AWS CloudFormation Resource Specification and additional checks. | https://github.com/aws-cloudformation/cfn-lint | 41 | | cfn-lint-serverless | Compilation of rules to validate infrastructure-as-code templates against recommended practices for serverless applications. | https://github.com/awslabs/serverless-rules | 42 | | @mhlabs/iam-policies-cli| CLI for generating AWS IAM policy documents or SAM policy templates based on the JSON definition used in the AWS Policy Generator. | https://github.com/mhlabs/iam-policies-cli | 43 | | @mhlabs/evb-cli | Pattern generator and debugging tool for Amazon EventBridge | https://github.com/mhlabs/evb-cli | 44 | -------------------------------------------------------------------------------- /docs/architecture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/aws-serverless-developer-experience-workshop-typescript/689b13ea49a2f53fdcec06051d90a64b4902f320/docs/architecture.png -------------------------------------------------------------------------------- /docs/workshop_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/aws-serverless-developer-experience-workshop-typescript/689b13ea49a2f53fdcec06051d90a64b4902f320/docs/workshop_logo.png -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import eslint from "@eslint/js"; 2 | import tseslint from "typescript-eslint"; 3 | import prettierConfig from 'eslint-config-prettier' 4 | 5 | export default tseslint.config( 6 | { 7 | ignores: ["**/node_modules", "**/.aws-sam", "**/template.yaml", "**/cdk.out", "**/.github", "**/jest.config.js", "**/.prettierrc.js"], 8 | }, 9 | eslint.configs.recommended, 10 | tseslint.configs.stylistic, 11 | prettierConfig, 12 | { 13 | rules: { 14 | "no-unused-vars": "off", 15 | "@typescript-eslint/no-unused-vars": ["error"] 16 | } 17 | } 18 | ); -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "scripts": { 3 | "prepare": "husky" 4 | }, 5 | "devDependencies": { 6 | "@eslint/js": "^9.25.1", 7 | "eslint": "^9.25.1", 8 | "eslint-config-prettier": "^10.1.2", 9 | "globals": "^16.0.0", 10 | "husky": "^9.1.7", 11 | "prettier": "^3.5.3", 12 | "typescript-eslint": "^8.31.1" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - unicorn_shared 3 | - unicorn_contracts 4 | - unicorn_properties 5 | - unicorn_web 6 | -------------------------------------------------------------------------------- /unicorn_contracts/.gitignore: -------------------------------------------------------------------------------- 1 | ### CDK-specific ignores ### 2 | *.swp 3 | cdk.context.json 4 | package-lock.json 5 | yarn.lock 6 | .cdk.staging 7 | cdk.out 8 | 9 | # Created by https://www.toptal.com/developers/gitignore/api/node 10 | # Edit at https://www.toptal.com/developers/gitignore?templates=node 11 | 12 | ### Node ### 13 | # Logs 14 | logs 15 | *.log 16 | npm-debug.log* 17 | yarn-debug.log* 18 | yarn-error.log* 19 | lerna-debug.log* 20 | .pnpm-debug.log* 21 | 22 | # Diagnostic reports (https://nodejs.org/api/report.html) 23 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 24 | 25 | # Runtime data 26 | pids 27 | *.pid 28 | *.seed 29 | *.pid.lock 30 | 31 | # Directory for instrumented libs generated by jscoverage/JSCover 32 | lib-cov 33 | 34 | # Coverage directory used by tools like istanbul 35 | coverage 36 | *.lcov 37 | 38 | # nyc test coverage 39 | .nyc_output 40 | 41 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 42 | .grunt 43 | 44 | # Bower dependency directory (https://bower.io/) 45 | bower_components 46 | 47 | # node-waf configuration 48 | .lock-wscript 49 | 50 | # Compiled binary addons (https://nodejs.org/api/addons.html) 51 | build/Release 52 | 53 | # Dependency directories 54 | node_modules/ 55 | jspm_packages/ 56 | 57 | # Snowpack dependency directory (https://snowpack.dev/) 58 | web_modules/ 59 | 60 | # TypeScript cache 61 | *.tsbuildinfo 62 | 63 | # Optional npm cache directory 64 | .npm 65 | 66 | # Optional eslint cache 67 | .eslintcache 68 | 69 | # Optional stylelint cache 70 | .stylelintcache 71 | 72 | # Microbundle cache 73 | .rpt2_cache/ 74 | .rts2_cache_cjs/ 75 | .rts2_cache_es/ 76 | .rts2_cache_umd/ 77 | 78 | # Optional REPL history 79 | .node_repl_history 80 | 81 | # Output of 'npm pack' 82 | *.tgz 83 | 84 | # Yarn Integrity file 85 | .yarn-integrity 86 | 87 | # dotenv environment variable files 88 | .env 89 | .env.development.local 90 | .env.test.local 91 | .env.production.local 92 | .env.local 93 | 94 | # parcel-bundler cache (https://parceljs.org/) 95 | .cache 96 | .parcel-cache 97 | 98 | # Next.js build output 99 | .next 100 | out 101 | 102 | # Nuxt.js build / generate output 103 | .nuxt 104 | dist 105 | 106 | # Gatsby files 107 | .cache/ 108 | # Comment in the public line in if your project uses Gatsby and not Next.js 109 | # https://nextjs.org/blog/next-9-1#public-directory-support 110 | # public 111 | 112 | # vuepress build output 113 | .vuepress/dist 114 | 115 | # vuepress v2.x temp and cache directory 116 | .temp 117 | 118 | # Docusaurus cache and generated files 119 | .docusaurus 120 | 121 | # Serverless directories 122 | .serverless/ 123 | 124 | # FuseBox cache 125 | .fusebox/ 126 | 127 | # DynamoDB Local files 128 | .dynamodb/ 129 | 130 | # TernJS port file 131 | .tern-port 132 | 133 | # Stores VSCode versions used for testing VSCode extensions 134 | .vscode-test 135 | 136 | # yarn v2 137 | .yarn/cache 138 | .yarn/unplugged 139 | .yarn/build-state.yml 140 | .yarn/install-state.gz 141 | .pnp.* 142 | 143 | ### Node Patch ### 144 | # Serverless Webpack directories 145 | .webpack/ 146 | 147 | # Optional stylelint cache 148 | 149 | # SvelteKit build / generate output 150 | .svelte-kit 151 | 152 | # End of https://www.toptal.com/developers/gitignore/api/node 153 | 154 | ### Linux ### 155 | **/*~ 156 | **/.fuse_hidden* 157 | **/.directory 158 | **/.Trash-* 159 | **/.nfs* 160 | 161 | ### OSX ### 162 | **/*.DS_Store 163 | **/.AppleDouble 164 | **/.LSOverride 165 | **/.DocumentRevisions-V100 166 | **/.fseventsd 167 | **/.Spotlight-V100 168 | **/.TemporaryItems 169 | **/.Trashes 170 | **/.VolumeIcon.icns 171 | **/.com.apple.timemachine.donotpresent 172 | 173 | ### JetBrains IDEs ### 174 | **/*.iws 175 | **/.idea/ 176 | **/.idea_modules/ 177 | 178 | ### Python ### 179 | **/__pycache__/ 180 | **/*.py[cod] 181 | **/*$py.class 182 | **/.Python 183 | **/build/ 184 | **/develop-eggs/ 185 | **/dist/ 186 | **/downloads/ 187 | **/eggs/ 188 | **/.eggs/ 189 | **/parts/ 190 | **/sdist/ 191 | **/wheels/ 192 | **/*.egg 193 | **/*.egg-info/ 194 | **/.installed.cfg 195 | **/pip-log.txt 196 | **/pip-delete-this-directory.txt 197 | 198 | ### Unit test / coverage reports ### 199 | **/.cache 200 | **/.coverage 201 | **/.hypothesis/ 202 | **/.pytest_cache/ 203 | **/.tox/ 204 | **/*.cover 205 | **/coverage.xml 206 | **/htmlcov/ 207 | **/nosetests.xml 208 | 209 | ### pyenv ### 210 | **/.python-version 211 | 212 | ### Environments ### 213 | **/.env 214 | **/.venv 215 | **/env/ 216 | **/venv/ 217 | **/ENV/ 218 | **/env.bak/ 219 | **/venv.bak/ 220 | 221 | ### VisualStudioCode ### 222 | **/.vscode/ 223 | **/.history 224 | 225 | ### Windows ### 226 | # Windows thumbnail cache files 227 | **/Thumbs.db 228 | **/ehthumbs.db 229 | **/ehthumbs_vista.db 230 | **/Desktop.ini 231 | **/$RECYCLE.BIN/ 232 | **/*.lnk 233 | 234 | ### requirements.txt ### 235 | # We ignore Python's requirements.txt as we use Poetry instead 236 | **/requirements.txt 237 | **/.aws-sam -------------------------------------------------------------------------------- /unicorn_contracts/.npmignore: -------------------------------------------------------------------------------- 1 | tests/* 2 | -------------------------------------------------------------------------------- /unicorn_contracts/.prettierignore: -------------------------------------------------------------------------------- 1 | **/template.yaml -------------------------------------------------------------------------------- /unicorn_contracts/.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | trailingComma: "es5", 3 | tabWidth: 2, 4 | semi: true, 5 | singleQuote: true, 6 | }; 7 | 8 | -------------------------------------------------------------------------------- /unicorn_contracts/Makefile: -------------------------------------------------------------------------------- 1 | #### Global Variables 2 | stackName := $(shell yq -oy '.default.global.parameters.stack_name' samconfig.yaml) 3 | 4 | 5 | #### Build/Deploy Tasks 6 | ci: deps clean build deploy 7 | deps: 8 | pnpm i 9 | 10 | build: 11 | sam build -c $(DOCKER_OPTS) 12 | 13 | deploy: deps build 14 | sam deploy --no-confirm-changeset 15 | 16 | 17 | 18 | #### Test Variables 19 | apiUrl = $(call cf_output,$(stackName),ApiUrl) 20 | ddbPropertyId = $(call get_ddb_key,create_contract_valid_payload_1) 21 | 22 | 23 | 24 | #### Tests 25 | 26 | test: unit-test integration-test 27 | 28 | unit-test: 29 | 30 | integration-test: 31 | pnpm test -- tests/integration/ 32 | 33 | curl-test: clean-tests 34 | $(call runif,CREATE CONTRACT) 35 | $(call mcurl,POST,create_contract_valid_payload_1) 36 | 37 | $(call runif,Query DDB) 38 | $(call ddb_get,$(ddbPropertyId)) 39 | 40 | $(call runif,UPDATE CONTRACT) 41 | $(call mcurl,PUT,update_existing_contract_valid_payload_1) 42 | 43 | $(call runif,Query DDB) 44 | $(call ddb_get,$(ddbPropertyId)) 45 | 46 | $(call runif,Delete DDB Items) 47 | $(MAKE) clean-tests 48 | 49 | @echo "[DONE]" 50 | 51 | 52 | clean-tests: 53 | $(call ddb_delete,$(ddbPropertyId)) || true 54 | 55 | 56 | #### Utilities 57 | sync: 58 | sam sync --stack-name $(stackName) --watch 59 | 60 | logs: 61 | sam logs --stack-name $(stackName) -t 62 | 63 | clean: 64 | find . -type d -name __pycache__ -exec rm -rf {} \; 2>/dev/null || true 65 | find . -type f -name requirements.txt -exec rm -f {} \; 2>/dev/null || true 66 | pnpm exec jest --clearCache 67 | rm -rf .pytest_cache/ .aws-sam/ htmlcov/ .coverage node_modules/ || true 68 | 69 | delete: 70 | sam delete --stack-name $(stackName) --no-prompts 71 | 72 | 73 | #### Helper Functions 74 | define runif 75 | @echo 76 | @echo "Run $(1) now? (Y/n)" 77 | @read 78 | @echo "Running $(1)" 79 | endef 80 | 81 | define ddb_get 82 | @aws dynamodb get-item \ 83 | --table-name $(call cf_output,$(stackName),ContractsTableName) \ 84 | --key '$(1)' \ 85 | | jq -f tests/integration/transformations/ddb_contract.jq \ 86 | | diff -y tests/integration/events/create_contract_valid_payload_1.json - || : 87 | endef 88 | 89 | define ddb_delete 90 | aws dynamodb delete-item \ 91 | --table-name $(call cf_output,$(stackName),ContractsTableName) \ 92 | --key '$(1)' 93 | endef 94 | 95 | define mcurl 96 | curl -X $(1) -H "Content-type: application/json" -d @$(call payload,$(2)) $(apiUrl)contract 97 | endef 98 | 99 | define get_ddb_key 100 | $(shell jq '. | {property_id:{S:.property_id}}' $(call payload,$(1)) | tr -d ' ') 101 | endef 102 | 103 | define payload 104 | tests/integration/events/$(1).json 105 | endef 106 | 107 | define cf_output 108 | $(shell aws cloudformation describe-stacks \ 109 | --output text \ 110 | --stack-name $(1) \ 111 | --query 'Stacks[0].Outputs[?OutputKey==`$(2)`].OutputValue') 112 | endef 113 | -------------------------------------------------------------------------------- /unicorn_contracts/README.md: -------------------------------------------------------------------------------- 1 | # Developing Unicorn Contracts 2 | 3 | ![Contracts Service Architecture](https://static.us-east-1.prod.workshops.aws/public/f273b5fc-17cd-406b-9e63-1d331b00589d/static/images/architecture-contracts.png) 4 | 5 | ## Architecture overview 6 | 7 | Unicorn Contract manages the contractual relationship between the customers and the Unicorn Properties agency. It's primary function is to allow Unicorn Properties agents to create a new contract for a property listing, and to have the contract approved once it's ready. 8 | 9 | The architecture is fairly straight forward. An API exposes the create contract and update contract methods. This information is recorded in a Amazon DynamoDB table which will contain all latest information about the contract and it's status. 10 | 11 | Each time a new contract is created or updated, Unicorn Contracts publishes a `ContractStatusChanged` event to Amazon EventBridge signalling changes to the contract status. These events are consumed by **Unicorn Properties**, so it can track changes to contracts, without needing to take a direct dependency on Unicorn Contracts and it's database. 12 | 13 | Here is an example of an event that is published to EventBridge: 14 | 15 | ```json 16 | { 17 | "version": "0", 18 | "account": "123456789012", 19 | "region": "us-east-1", 20 | "detail-type": "ContractStatusChanged", 21 | "source": "unicorn.contracts", 22 | "time": "2022-08-14T22:06:31Z", 23 | "id": "c071bfbf-83c4-49ca-a6ff-3df053957145", 24 | "resources": [], 25 | "detail": { 26 | "contract_updated_on": "10/08/2022 19:56:30", 27 | "ContractId": "617dda8c-e79b-406a-bc5b-3a4712f5e4d7", 28 | "PropertyId": "usa/anytown/main-street/111", 29 | "ContractStatus": "DRAFT" 30 | } 31 | } 32 | ``` 33 | 34 | ### Testing the APIs 35 | 36 | ```bash 37 | export API=`aws cloudformation describe-stacks --stack-name uni-prop-local-contract --query "Stacks[0].Outputs[?OutputKey=='ApiUrl'].OutputValue" --output text` 38 | 39 | curl --location --request POST "${API}contract" \ 40 | --header 'Content-Type: application/json' \ 41 | --data-raw '{ 42 | "address": { 43 | "country": "USA", 44 | "city": "Anytown", 45 | "street": "Main Street", 46 | "number": 111 47 | }, 48 | "seller_name": "John Doe", 49 | "property_id": "usa/anytown/main-street/111" 50 | }' 51 | 52 | 53 | curl --location --request PUT "${API}contract" \ 54 | --header 'Content-Type: application/json' \ 55 | --data-raw '{"property_id": "usa/anytown/main-street/111"}' | jq 56 | ``` 57 | -------------------------------------------------------------------------------- /unicorn_contracts/eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import eslint from "@eslint/js"; 2 | import tseslint from "typescript-eslint"; 3 | import prettierConfig from 'eslint-config-prettier' 4 | 5 | export default tseslint.config( 6 | { 7 | ignores: ["**/node_modules", "**/.aws-sam", "**/template.yaml", "**/cdk.out", "**/.github", "**/jest.config.js", "**/.prettierrc.js"], 8 | }, 9 | eslint.configs.recommended, 10 | tseslint.configs.stylistic, 11 | prettierConfig, 12 | { 13 | rules: { 14 | "no-unused-vars": "off", 15 | "@typescript-eslint/no-unused-vars": ["error"] 16 | } 17 | } 18 | ); -------------------------------------------------------------------------------- /unicorn_contracts/integration/ContractStatusChanged.json: -------------------------------------------------------------------------------- 1 | { 2 | "openapi": "3.0.0", 3 | "info": { 4 | "version": "1.0.0", 5 | "title": "ContractStatusChanged" 6 | }, 7 | "paths": {}, 8 | "components": { 9 | "schemas": { 10 | "AWSEvent": { 11 | "type": "object", 12 | "required": [ 13 | "detail-type", 14 | "resources", 15 | "detail", 16 | "id", 17 | "source", 18 | "time", 19 | "region", 20 | "version", 21 | "account" 22 | ], 23 | "x-amazon-events-detail-type": "ContractStatusChanged", 24 | "x-amazon-events-source": "unicorn.contracts", 25 | "properties": { 26 | "detail": { 27 | "$ref": "#/components/schemas/ContractStatusChanged" 28 | }, 29 | "account": { 30 | "type": "string" 31 | }, 32 | "detail-type": { 33 | "type": "string" 34 | }, 35 | "id": { 36 | "type": "string" 37 | }, 38 | "region": { 39 | "type": "string" 40 | }, 41 | "resources": { 42 | "type": "array", 43 | "items": { 44 | "type": "object" 45 | } 46 | }, 47 | "source": { 48 | "type": "string" 49 | }, 50 | "time": { 51 | "type": "string", 52 | "format": "date-time" 53 | }, 54 | "version": { 55 | "type": "string" 56 | } 57 | } 58 | }, 59 | "ContractStatusChanged": { 60 | "type": "object", 61 | "required": [ 62 | "contract_last_modified_on", 63 | "contract_id", 64 | "contract_status", 65 | "property_id" 66 | ], 67 | "properties": { 68 | "contract_id": { 69 | "type": "string" 70 | }, 71 | "contract_last_modified_on": { 72 | "type": "string", 73 | "format": "date-time" 74 | }, 75 | "contract_status": { 76 | "type": "string" 77 | }, 78 | "property_id": { 79 | "type": "string" 80 | } 81 | } 82 | } 83 | } 84 | } 85 | } -------------------------------------------------------------------------------- /unicorn_contracts/integration/subscriber-policies.yaml: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: MIT-0 3 | AWSTemplateFormatVersion: "2010-09-09" 4 | Description: > 5 | Defines the event bus policies that determine who can create rules on the event bus to 6 | subscribe to events published by the Contracts Service. 7 | 8 | Parameters: 9 | Stage: 10 | Type: String 11 | Default: local 12 | AllowedValues: 13 | - local 14 | - dev 15 | - prod 16 | 17 | Resources: 18 | # This policy defines who can create rules on the event bus. Only principals subscribing to 19 | # Contracts Service events can create rule on the bus. No rules without a defined source. 20 | CrossServiceCreateRulePolicy: 21 | Type: AWS::Events::EventBusPolicy 22 | Properties: 23 | EventBusName: 24 | Fn::Sub: "{{resolve:ssm:/uni-prop/${Stage}/UnicornContractsEventBus}}" 25 | StatementId: 26 | Fn::Sub: "OnlyRulesForContractServiceEvents-${Stage}" 27 | Statement: 28 | Effect: Allow 29 | Principal: 30 | AWS: 31 | Fn::Sub: "arn:${AWS::Partition}:iam::${AWS::AccountId}:root" 32 | Action: 33 | - events:PutRule 34 | - events:DeleteRule 35 | - events:DescribeRule 36 | - events:DisableRule 37 | - events:EnableRule 38 | - events:PutTargets 39 | - events:RemoveTargets 40 | Resource: 41 | - Fn::Sub: 42 | - arn:${AWS::Partition}:events:${AWS::Region}:${AWS::AccountId}:rule/${eventBusName}/* 43 | - eventBusName: 44 | Fn::Sub: "{{resolve:ssm:/uni-prop/${Stage}/UnicornContractsEventBus}}" 45 | Condition: 46 | StringEqualsIfExists: 47 | "events:creatorAccount": "${aws:PrincipalAccount}" 48 | StringEquals: 49 | "events:source": 50 | - "{{resolve:ssm:/uni-prop/UnicornContractsNamespace}}" 51 | "Null": 52 | "events:source": "false" 53 | -------------------------------------------------------------------------------- /unicorn_contracts/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: "ts-jest", 3 | transform: { 4 | "^.+\\.ts?$": "ts-jest", 5 | }, 6 | moduleFileExtensions: ["js", "ts"], 7 | collectCoverageFrom: ["**/src/**/*.ts", "!**/node_modules/**"], 8 | testMatch: ["**/tests/unit/*.test.ts", "**/tests/integration/*.test.ts"], 9 | testPathIgnorePatterns: ["/node_modules/"], 10 | testEnvironment: "node", 11 | coverageProvider: "v8", 12 | }; 13 | -------------------------------------------------------------------------------- /unicorn_contracts/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "contracts", 3 | "version": "1.0.0", 4 | "description": "Contracts Module for Serverless Developer Experience Reference Architecture - Node", 5 | "repository": "https://github.com/aws-samples/aws-serverless-developer-experience-workshop-typescript/tree/develop", 6 | "license": "MIT", 7 | "author": "Amazon.com, Inc.", 8 | "main": "app.js", 9 | "scripts": { 10 | "build": "sam build", 11 | "compile": "tsc", 12 | "deploy": "sam build && sam deploy --no-confirm-changeset", 13 | "lint": "eslint --ext .ts --quiet --fix", 14 | "test": "jest --runInBand", 15 | "unit": "jest --config=jest.config.test.unit.ts" 16 | }, 17 | "dependencies": { 18 | "@aws-lambda-powertools/commons": "^2.20.0", 19 | "@aws-lambda-powertools/logger": "^2.20.0", 20 | "@aws-lambda-powertools/metrics": "^2.20.0", 21 | "@aws-lambda-powertools/tracer": "^2.20.0", 22 | "@aws-sdk/client-cloudformation": "^3.812.0", 23 | "@aws-sdk/client-cloudwatch-logs": "^3.812.0", 24 | "@aws-sdk/client-dynamodb": "^3.812.0", 25 | "@aws-sdk/client-eventbridge": "^3.812.0", 26 | "@aws-sdk/client-sqs": "^3.812.0", 27 | "@aws-sdk/lib-dynamodb": "^3.814.0", 28 | "@aws-sdk/util-dynamodb": "^3.812.0", 29 | "aws-lambda": "^1.0.7" 30 | }, 31 | "devDependencies": { 32 | "@aws-sdk/client-cloudformation": "^3.699.0", 33 | "@aws-sdk/client-cloudwatch-logs": "^3.699.0", 34 | "@types/aws-lambda": "^8.10.149", 35 | "@types/jest": "^29.5.14", 36 | "@types/node": "^22.15.21", 37 | "aws-sdk-client-mock": "^4.1.0", 38 | "esbuild": "^0.25.4", 39 | "esbuild-jest": "^0.5.0", 40 | "eslint": "^9.27.0", 41 | "eslint-config-prettier": "^10.1.5", 42 | "globals": "^16.1.0", 43 | "jest": "^29.7.0", 44 | "prettier": "^3.5.3", 45 | "ts-jest": "^29.3.4", 46 | "ts-node": "^10.9.2", 47 | "typescript": "^5.8.3", 48 | "typescript-eslint": "^8.32.1" 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /unicorn_contracts/samconfig.toml: -------------------------------------------------------------------------------- 1 | version = 0.1 2 | 3 | [default.global.parameters] 4 | stack_name = "uni-prop-local-contracts" 5 | s3_prefix = "uni-prop-local-contracts" 6 | resolve_s3 = true 7 | resolve_image_repositories = true 8 | 9 | [default.build.parameters] 10 | cached = true 11 | parallel = true 12 | 13 | [default.deploy.parameters] 14 | disable_rollback = true 15 | confirm_changeset = false 16 | fail_on_empty_changeset = false 17 | capabilities = ["CAPABILITY_IAM", "CAPABILITY_AUTO_EXPAND"] 18 | parameter_overrides = ["Stage=local"] 19 | 20 | [default.validate.parameters] 21 | lint = true 22 | 23 | [default.sync.parameters] 24 | watch = true 25 | 26 | [default.local_start_api.parameters] 27 | warm_containers = "EAGER" 28 | 29 | [default.local_start_lambda.parameters] 30 | warm_containers = "EAGER" -------------------------------------------------------------------------------- /unicorn_contracts/src/contracts_service/Contract.ts: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | 4 | /** 5 | * Defines the structure of a contract in the database. 6 | * 7 | * @property address - The address of the contract. 8 | * @property property_id - The ID of the property associated with the contract. 9 | * @property contract_id - The ID of the contract. 10 | * @property seller_name - The name of the seller. 11 | * @property contract_status - The status of the contract. 12 | * @property contract_created - The date the contract was created. 13 | * @property contract_last_modified_on - The date the contract was last modified. 14 | */ 15 | export interface ContractDBType { 16 | address?: string; 17 | property_id: string; 18 | contract_id?: string; 19 | seller_name?: string; 20 | contract_status: ContractStatusEnum; 21 | contract_created?: string; 22 | contract_last_modified_on?: string; 23 | } 24 | 25 | /** 26 | * Enumerates the possible status values for a contract. 27 | * 28 | * @enum {string} 29 | * @property APPROVED - The contract has been approved. 30 | * @property CANCELLED - The contract has been cancelled. 31 | * @property DRAFT - The contract is in draft status. 32 | * @property CLOSED - The contract has been closed. 33 | * @property EXPIRED - The contract has expired. 34 | */ 35 | export enum ContractStatusEnum { 36 | APPROVED = 'APPROVED', 37 | CANCELLED = 'CANCELLED', 38 | DRAFT = 'DRAFT', 39 | CLOSED = 'CLOSED', 40 | EXPIRED = 'EXPIRED', 41 | } 42 | 43 | /** 44 | * Defines an interface for a contract error that extends the base Error interface. 45 | * 46 | * @interface ContractError 47 | * @extends Error 48 | * @property propertyId - The ID of the property associated with the error. 49 | * @property object - The object associated with the error (optional). 50 | */ 51 | export interface ContractError extends Error { 52 | propertyId: string; 53 | object?: any; 54 | } 55 | 56 | /** 57 | * Defines an interface for a contract response. 58 | * 59 | * @interface ContractResponse 60 | * @property propertyId - The ID of the property associated with the response. 61 | * @property metadata - Additional metadata associated with the response. 62 | */ 63 | export interface ContractResponse { 64 | propertyId: string; 65 | metadata: any; 66 | } 67 | -------------------------------------------------------------------------------- /unicorn_contracts/src/contracts_service/powertools.ts: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | import { Metrics } from '@aws-lambda-powertools/metrics'; 4 | import { Logger } from '@aws-lambda-powertools/logger'; 5 | import type { LogLevel } from '@aws-lambda-powertools/logger/types'; 6 | import { Tracer } from '@aws-lambda-powertools/tracer'; 7 | 8 | const SERVICE_NAMESPACE = process.env.SERVICE_NAMESPACE ?? 'test_namespace'; 9 | 10 | const defaultValues = { 11 | region: process.env.AWS_REGION || 'N/A', 12 | executionEnv: process.env.AWS_EXECUTION_ENV || 'N/A', 13 | }; 14 | 15 | const logger = new Logger({ 16 | logLevel: (process.env.LOG_LEVEL || 'INFO') as LogLevel, 17 | persistentLogAttributes: { 18 | ...defaultValues, 19 | logger: { 20 | name: '@aws-lambda-powertools/logger', 21 | }, 22 | }, 23 | }); 24 | 25 | const metrics = new Metrics({ 26 | defaultDimensions: defaultValues, 27 | namespace: SERVICE_NAMESPACE, 28 | }); 29 | 30 | const tracer = new Tracer(); 31 | 32 | export { metrics, logger, tracer }; 33 | -------------------------------------------------------------------------------- /unicorn_contracts/tests/data/contract_data.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "property_id": "usa/anytown/main-street/111", 4 | "address": { 5 | "city": "Anytown", 6 | "country": "USA", 7 | "number": 111, 8 | "street": "Main Street" 9 | }, 10 | "contract_created": "2023-11-19T02:24:11.480Z", 11 | "contract_id": "21265809-bc6e-4d01-8ab8-2c22939f16d7", 12 | "contract_last_modified_on": "2023-11-19T02:24:11.480Z", 13 | "contract_status": "DRAFT", 14 | "seller_name": "John Doe" 15 | } 16 | ] -------------------------------------------------------------------------------- /unicorn_contracts/tests/integration/create_contract.test.ts: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | import { 4 | findOutputValue, 5 | clearDatabase, 6 | getCloudWatchLogsValues, 7 | sleep, 8 | } from './helper'; 9 | 10 | describe('Testing creating contracts', () => { 11 | let apiUrl: string; 12 | 13 | beforeAll(async () => { 14 | // Clear DB 15 | await clearDatabase(); 16 | // Find API Endpoint 17 | apiUrl = await findOutputValue('ApiUrl'); 18 | }); 19 | 20 | afterAll(async () => { 21 | // Clear DB 22 | await clearDatabase(); 23 | }); 24 | 25 | it('Should create a item in DynamoDB and fire a eventbridge event', async () => { 26 | const response = await fetch(`${apiUrl}contracts`, { 27 | method: 'POST', 28 | headers: { 'content-type': 'application/json' }, 29 | body: '{ "address": { "country": "USA", "city": "Anytown", "street": "Main Street", "number": 111 }, "seller_name": "John Doe", "property_id": "usa/anytown/main-street/111" }', 30 | }); 31 | expect(response.status).toBe(200); 32 | const json = await response.json(); 33 | expect(json).toEqual({ message: 'OK' }); 34 | await sleep(15000); 35 | const event = await getCloudWatchLogsValues( 36 | 'usa/anytown/main-street/111' 37 | ).next(); 38 | expect(event.value['detail-type']).toEqual('ContractStatusChanged'); 39 | expect(event.value['detail'].property_id).toEqual( 40 | 'usa/anytown/main-street/111' 41 | ); 42 | expect(event.value['detail'].contract_status).toEqual('DRAFT'); 43 | }, 30000); 44 | }); 45 | -------------------------------------------------------------------------------- /unicorn_contracts/tests/integration/helper.ts: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; 4 | import { 5 | CloudFormationClient, 6 | DescribeStacksCommand, 7 | DescribeStacksCommandOutput, 8 | } from '@aws-sdk/client-cloudformation'; 9 | import { 10 | BatchWriteCommand, 11 | DynamoDBDocumentClient, 12 | ScanCommand, 13 | } from '@aws-sdk/lib-dynamodb'; 14 | import { 15 | CloudWatchLogs, 16 | DescribeLogStreamsCommand, 17 | GetLogEventsCommand, 18 | } from '@aws-sdk/client-cloudwatch-logs'; 19 | import PropertyData from '../data/contract_data.json'; 20 | 21 | export const sleep = async (ms: number) => 22 | new Promise((resolve) => { 23 | setTimeout(resolve, ms); 24 | }); 25 | 26 | export async function* getCloudWatchLogsValues( 27 | propertyId: string 28 | ): AsyncGenerator { 29 | const groupName = ( 30 | await findOutputValue('UnicornContractsCatchAllLogGroupArn') 31 | ) 32 | .split(':') 33 | .slice(-2)[0]; 34 | 35 | // Initialize the CloudWatch Logs client 36 | const cwl = new CloudWatchLogs({ region: process.env.AWS_DEFAULT_REGION }); 37 | 38 | // Get the CW LogStream with the latest log messages 39 | const streamResponse = await cwl.send( 40 | new DescribeLogStreamsCommand({ 41 | logGroupName: groupName, 42 | orderBy: 'LastEventTime', 43 | descending: true, 44 | limit: 3, 45 | }) 46 | ); 47 | 48 | const latestLogStreamNames = (streamResponse.logStreams || []).map( 49 | (s) => s.logStreamName || '' 50 | ); 51 | 52 | // Fetch log events from that stream 53 | const responses = await Promise.all( 54 | latestLogStreamNames.map(async (name) => { 55 | return await cwl.send( 56 | new GetLogEventsCommand({ 57 | logGroupName: groupName, 58 | logStreamName: name, 59 | }) 60 | ); 61 | }) 62 | ); 63 | 64 | // Filter log events that match the required `propertyId` 65 | for (const response of responses) { 66 | for (const event of response.events || []) { 67 | const ev = JSON.parse(event.message || '{}'); 68 | if (ev.detail?.property_id === propertyId) { 69 | yield ev; 70 | } 71 | } 72 | } 73 | } 74 | 75 | export async function clearDatabase() { 76 | const client = new DynamoDBClient({ 77 | region: process.env.AWS_DEFAULT_REGION, 78 | }); 79 | const tableName = await findOutputValue('ContractsTableName'); 80 | 81 | const scanCommand = new ScanCommand({ TableName: tableName }); 82 | let itemsToDelete; 83 | try { 84 | const scanResponse = await client.send(scanCommand); 85 | itemsToDelete = scanResponse.Items; 86 | } catch (error) { 87 | console.error('Error scanning table:', error); 88 | } 89 | 90 | if (!itemsToDelete || itemsToDelete.length === 0) { 91 | console.log('No items to delete.'); 92 | return; 93 | } 94 | 95 | // Create an array of DeleteRequest objects for batch delete 96 | const batchWriteCommand = new BatchWriteCommand({ 97 | RequestItems: { 98 | [tableName]: itemsToDelete.map((item: any) => ({ 99 | DeleteRequest: { 100 | Key: { 101 | property_id: item.property_id, 102 | }, 103 | }, 104 | })), 105 | }, 106 | }); 107 | 108 | // Execute the batch write command to delete all items 109 | try { 110 | await client.send(batchWriteCommand); 111 | } catch (error) { 112 | console.error('Error batch deleting items:', error); 113 | } 114 | } 115 | 116 | export const initialiseDatabase = async () => { 117 | const tableName = await findOutputValue('ContractsTableName'); 118 | const client = new DynamoDBClient({ 119 | region: process.env.AWS_DEFAULT_REGION, 120 | }); 121 | const docClient = DynamoDBDocumentClient.from(client); 122 | const command = new BatchWriteCommand({ 123 | RequestItems: { 124 | [tableName]: PropertyData.map((property) => ({ 125 | PutRequest: { Item: property }, 126 | })), 127 | }, 128 | }); 129 | return await docClient.send(command); 130 | }; 131 | 132 | export const findOutputValue = async (outputKey: string) => { 133 | const cloudformation = new CloudFormationClient({ 134 | region: process.env.AWS_DEFAULT_REGION, 135 | }); 136 | const stackResources: DescribeStacksCommandOutput = await cloudformation.send( 137 | new DescribeStacksCommand({ StackName: 'uni-prop-local-contracts' }) 138 | ); 139 | if (stackResources.Stacks === undefined || stackResources.Stacks?.length < 1) 140 | throw new Error( 141 | 'Could not find stack resources named: uni-prop-local-contracts ' 142 | ); 143 | 144 | if ( 145 | stackResources.Stacks[0].Outputs === undefined || 146 | stackResources.Stacks[0].Outputs?.length < 1 147 | ) { 148 | throw new Error( 149 | 'Could not find stack outputs for stack named: uni-prop-local-contracts' 150 | ); 151 | } 152 | 153 | const outputValue = stackResources.Stacks[0].Outputs.find( 154 | (output) => output.OutputKey === outputKey 155 | )?.OutputValue; 156 | if (outputValue === undefined) 157 | throw new Error(`Could not find stack output named: ${outputKey}`); 158 | return outputValue; 159 | }; 160 | -------------------------------------------------------------------------------- /unicorn_contracts/tests/integration/transformations/ddb_contract.jq: -------------------------------------------------------------------------------- 1 | .Item | { 2 | property_id: .property_id.S, 3 | contract_id: .contract_id.S, 4 | seller_name: .seller_name.S, 5 | address: { 6 | country: .address.M.country.S, 7 | number: .address.M.number.N, 8 | city: .address.M.city.S, 9 | street: .address.M.street.S, 10 | }, 11 | contract_status: .contract_status.S, 12 | contract_created: .contract_created.S, 13 | contract_last_modified_on: .contract_last_modified_on.S 14 | } | del(..|nulls) 15 | -------------------------------------------------------------------------------- /unicorn_contracts/tests/integration/update_contract.test.ts: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | import { 4 | initialiseDatabase, 5 | findOutputValue, 6 | clearDatabase, 7 | getCloudWatchLogsValues, 8 | sleep, 9 | } from './helper'; 10 | 11 | describe('Testing updating contracts', () => { 12 | let apiUrl: string; 13 | 14 | beforeAll(async () => { 15 | // Clear DB 16 | await clearDatabase(); 17 | // Load data 18 | await initialiseDatabase(); 19 | // Find API Endpoint 20 | apiUrl = await findOutputValue('ApiUrl'); 21 | // Create DRAFT contract 22 | await fetch(`${apiUrl}contracts`, { 23 | method: 'POST', 24 | headers: { 'content-type': 'application/json' }, 25 | body: '{ "address": { "country": "USA", "city": "Anytown", "street": "Main Street", "number": 111 }, "seller_name": "John Doe", "property_id": "usa/anytown/main-street/111" }', 26 | }); 27 | }, 40000); 28 | 29 | afterAll(async () => { 30 | // Clear DB 31 | await clearDatabase(); 32 | }); 33 | 34 | it('Should update the item in DynamoDB and fire a eventbridge event when an existing contract is updated', async () => { 35 | const response = await fetch(`${apiUrl}contracts`, { 36 | method: 'PUT', 37 | headers: { 'content-type': 'application/json' }, 38 | body: '{"property_id":"usa/anytown/main-street/111"}', 39 | }); 40 | expect(response.status).toBe(200); 41 | const json = await response.json(); 42 | expect(json).toEqual({ message: 'OK' }); 43 | await sleep(15000); 44 | const event = await getCloudWatchLogsValues( 45 | 'usa/anytown/main-street/111' 46 | ).next(); 47 | expect(event.value['detail-type']).toEqual('ContractStatusChanged'); 48 | expect(event.value['detail'].property_id).toEqual( 49 | 'usa/anytown/main-street/111' 50 | ); 51 | expect(event.value['detail'].contract_status).toEqual('APPROVED'); 52 | }, 30000); 53 | }); 54 | -------------------------------------------------------------------------------- /unicorn_contracts/tests/transformations/ddb_contract.jq: -------------------------------------------------------------------------------- 1 | .Item | { 2 | property_id: .property_id.S, 3 | contract_id: .contract_id.S, 4 | seller_name: .seller_name.S, 5 | address: { 6 | country: .address.M.country.S, 7 | number: .address.M.number.N, 8 | city: .address.M.city.S, 9 | street: .address.M.street.S, 10 | }, 11 | contract_status: .contract_status.S, 12 | contract_created: .contract_created.S, 13 | contract_last_modified_on: .contract_last_modified_on.S 14 | } | del(..|nulls) 15 | -------------------------------------------------------------------------------- /unicorn_contracts/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2020", 4 | "strict": true, 5 | "types": ["node", "jest"], 6 | "baseUrl": "./", 7 | "paths": { 8 | "src/*": ["src/*"], 9 | "tests/*": ["tests/*"] 10 | }, 11 | "resolveJsonModule": true, 12 | "preserveConstEnums": true, 13 | "noEmit": true, 14 | "sourceMap": false, 15 | "module":"es2015", 16 | "moduleResolution":"node", 17 | "esModuleInterop": true, 18 | "skipLibCheck": true, 19 | "forceConsistentCasingInFileNames": true, 20 | "experimentalDecorators": true 21 | }, 22 | "exclude": ["node_modules"] 23 | } -------------------------------------------------------------------------------- /unicorn_properties/.gitignore: -------------------------------------------------------------------------------- 1 | ### CDK-specific ignores ### 2 | *.swp 3 | cdk.context.json 4 | package-lock.json 5 | yarn.lock 6 | .cdk.staging 7 | cdk.out 8 | 9 | # Created by https://www.toptal.com/developers/gitignore/api/node 10 | # Edit at https://www.toptal.com/developers/gitignore?templates=node 11 | 12 | ### Node ### 13 | # Logs 14 | logs 15 | *.log 16 | npm-debug.log* 17 | yarn-debug.log* 18 | yarn-error.log* 19 | lerna-debug.log* 20 | .pnpm-debug.log* 21 | 22 | # Diagnostic reports (https://nodejs.org/api/report.html) 23 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 24 | 25 | # Runtime data 26 | pids 27 | *.pid 28 | *.seed 29 | *.pid.lock 30 | 31 | # Directory for instrumented libs generated by jscoverage/JSCover 32 | lib-cov 33 | 34 | # Coverage directory used by tools like istanbul 35 | coverage 36 | *.lcov 37 | 38 | # nyc test coverage 39 | .nyc_output 40 | 41 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 42 | .grunt 43 | 44 | # Bower dependency directory (https://bower.io/) 45 | bower_components 46 | 47 | # node-waf configuration 48 | .lock-wscript 49 | 50 | # Compiled binary addons (https://nodejs.org/api/addons.html) 51 | build/Release 52 | 53 | # Dependency directories 54 | node_modules/ 55 | jspm_packages/ 56 | 57 | # Snowpack dependency directory (https://snowpack.dev/) 58 | web_modules/ 59 | 60 | # TypeScript cache 61 | *.tsbuildinfo 62 | 63 | # Optional npm cache directory 64 | .npm 65 | 66 | # Optional eslint cache 67 | .eslintcache 68 | 69 | # Optional stylelint cache 70 | .stylelintcache 71 | 72 | # Microbundle cache 73 | .rpt2_cache/ 74 | .rts2_cache_cjs/ 75 | .rts2_cache_es/ 76 | .rts2_cache_umd/ 77 | 78 | # Optional REPL history 79 | .node_repl_history 80 | 81 | # Output of 'npm pack' 82 | *.tgz 83 | 84 | # Yarn Integrity file 85 | .yarn-integrity 86 | 87 | # dotenv environment variable files 88 | .env 89 | .env.development.local 90 | .env.test.local 91 | .env.production.local 92 | .env.local 93 | 94 | # parcel-bundler cache (https://parceljs.org/) 95 | .cache 96 | .parcel-cache 97 | 98 | # Next.js build output 99 | .next 100 | out 101 | 102 | # Nuxt.js build / generate output 103 | .nuxt 104 | dist 105 | 106 | # Gatsby files 107 | .cache/ 108 | # Comment in the public line in if your project uses Gatsby and not Next.js 109 | # https://nextjs.org/blog/next-9-1#public-directory-support 110 | # public 111 | 112 | # vuepress build output 113 | .vuepress/dist 114 | 115 | # vuepress v2.x temp and cache directory 116 | .temp 117 | 118 | # Docusaurus cache and generated files 119 | .docusaurus 120 | 121 | # Serverless directories 122 | .serverless/ 123 | 124 | # FuseBox cache 125 | .fusebox/ 126 | 127 | # DynamoDB Local files 128 | .dynamodb/ 129 | 130 | # TernJS port file 131 | .tern-port 132 | 133 | # Stores VSCode versions used for testing VSCode extensions 134 | .vscode-test 135 | 136 | # yarn v2 137 | .yarn/cache 138 | .yarn/unplugged 139 | .yarn/build-state.yml 140 | .yarn/install-state.gz 141 | .pnp.* 142 | 143 | ### Node Patch ### 144 | # Serverless Webpack directories 145 | .webpack/ 146 | 147 | # Optional stylelint cache 148 | 149 | # SvelteKit build / generate output 150 | .svelte-kit 151 | 152 | # End of https://www.toptal.com/developers/gitignore/api/node 153 | 154 | ### Linux ### 155 | **/*~ 156 | **/.fuse_hidden* 157 | **/.directory 158 | **/.Trash-* 159 | **/.nfs* 160 | 161 | ### OSX ### 162 | **/*.DS_Store 163 | **/.AppleDouble 164 | **/.LSOverride 165 | **/.DocumentRevisions-V100 166 | **/.fseventsd 167 | **/.Spotlight-V100 168 | **/.TemporaryItems 169 | **/.Trashes 170 | **/.VolumeIcon.icns 171 | **/.com.apple.timemachine.donotpresent 172 | 173 | ### JetBrains IDEs ### 174 | **/*.iws 175 | **/.idea/ 176 | **/.idea_modules/ 177 | 178 | ### Python ### 179 | **/__pycache__/ 180 | **/*.py[cod] 181 | **/*$py.class 182 | **/.Python 183 | **/build/ 184 | **/develop-eggs/ 185 | **/dist/ 186 | **/downloads/ 187 | **/eggs/ 188 | **/.eggs/ 189 | **/parts/ 190 | **/sdist/ 191 | **/wheels/ 192 | **/*.egg 193 | **/*.egg-info/ 194 | **/.installed.cfg 195 | **/pip-log.txt 196 | **/pip-delete-this-directory.txt 197 | 198 | ### Unit test / coverage reports ### 199 | **/.cache 200 | **/.coverage 201 | **/.hypothesis/ 202 | **/.pytest_cache/ 203 | **/.tox/ 204 | **/*.cover 205 | **/coverage.xml 206 | **/htmlcov/ 207 | **/nosetests.xml 208 | 209 | ### pyenv ### 210 | **/.python-version 211 | 212 | ### Environments ### 213 | **/.env 214 | **/.venv 215 | **/env/ 216 | **/venv/ 217 | **/ENV/ 218 | **/env.bak/ 219 | **/venv.bak/ 220 | 221 | ### VisualStudioCode ### 222 | **/.vscode/ 223 | **/.history 224 | 225 | ### Windows ### 226 | # Windows thumbnail cache files 227 | **/Thumbs.db 228 | **/ehthumbs.db 229 | **/ehthumbs_vista.db 230 | **/Desktop.ini 231 | **/$RECYCLE.BIN/ 232 | **/*.lnk 233 | 234 | ### requirements.txt ### 235 | # We ignore Python's requirements.txt as we use Poetry instead 236 | **/requirements.txt 237 | **/.aws-sam -------------------------------------------------------------------------------- /unicorn_properties/.npmignore: -------------------------------------------------------------------------------- 1 | tests/* 2 | -------------------------------------------------------------------------------- /unicorn_properties/.prettierignore: -------------------------------------------------------------------------------- 1 | **/template.yaml -------------------------------------------------------------------------------- /unicorn_properties/.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | trailingComma: "es5", 3 | tabWidth: 2, 4 | semi: true, 5 | singleQuote: true, 6 | }; 7 | 8 | -------------------------------------------------------------------------------- /unicorn_properties/Makefile: -------------------------------------------------------------------------------- 1 | #### Global Variables 2 | stackName := $(shell yq -oy '.default.global.parameters.stack_name' samconfig.yaml) 3 | 4 | 5 | #### Build/Deploy Tasks 6 | ci: deps clean build deploy 7 | deps: 8 | pnpm i 9 | 10 | build: 11 | sam build -c $(DOCKER_OPTS) 12 | 13 | deploy: deps build 14 | sam deploy --no-confirm-changeset 15 | 16 | 17 | 18 | #### Tests 19 | test: unit-test integration-test 20 | 21 | unit-test: 22 | pnpm test -- tests/unit/ 23 | 24 | integration-test: 25 | pnpm test -- tests/integration/ 26 | 27 | #### Utilities 28 | sync: 29 | sam sync --stack-name $(stackName) --watch 30 | 31 | logs: 32 | sam logs --stack-name $(stackName) -t 33 | 34 | clean: 35 | find . -type d -name __pycache__ -exec rm -rf {} \; 2>/dev/null || true 36 | find . -type f -name requirements.txt -exec rm -f {} \; 2>/dev/null || true 37 | pnpm exec jest --clearCache 38 | rm -rf node_modules/ .pytest_cache/ .aws-sam/ htmlcov/ .coverage || true 39 | 40 | delete: 41 | sam delete --stack-name $(stackName) --no-prompts 42 | 43 | ci_init: 44 | pnpm i 45 | -------------------------------------------------------------------------------- /unicorn_properties/README.md: -------------------------------------------------------------------------------- 1 | # Developing Unicorn Properties 2 | 3 | ![Properties Approval Architecture](https://static.us-east-1.prod.workshops.aws/public/f273b5fc-17cd-406b-9e63-1d331b00589d/static/images/architecture-properties.png) 4 | 5 | ## Architecture overview 6 | 7 | Unicorn Properties is primarily responsible for approving property listings for Unicorn Web. 8 | 9 | A core component of Unicorn Properties is the approvals workflow. The approvals workflow is implemented using an AWS Step Functions state machine. At a high level, the workflow will: 10 | 11 | * Check whether or not it has any contract information for the property it needs to approve. If there is no contract information, the approval process cannot be completed. 12 | * Ensure the sentiment of the property description is positive and that there no unsafe images. All checks must pass for the listing to be made public. 13 | * Ensure that the contract is in an APPROVED state before it can approve the listing. This accounts for a situation where the property listings are created before the contract has been signed and the services for Unicorn Properties are paid for. 14 | * Publish the result of the workflow via the `PublicationEvaluationCompleted` event. 15 | 16 | The workflow is initiated by a request made by an Unicorn Properties **agent** to have the property approved for publication. Once they have created a property listing (added property details and photos), they initiate the request in Unicorn Web, which generates a `PublicationApprovalRequested` event. This event contains the property information which the workflow processes. 17 | 18 | In order process the approvals workflow successfully, the properties service needs to know the current status of a contract. To remain fully decoupled from the **Contracts Service**, it maintains a local copy of contract status by consuming the `ContractStatusChanged` event. This is eliminates the need for the Contracts service to expose an API that gives other services access to its database, and allows the Properties service to function autonomously. 19 | 20 | When the workflow is paused to check to see whether or not the contract is in an approved state, the `WaitForContractApproval` state will update a contract status for a specified property with its task token. This initiates a stream event on the DynamoDB table. The Property approvals sync function handles DynamoDB stream events. It determines whether or not to pass AWS Step Function task token back to the state machine based on the contract state. 21 | 22 | If workflow is completed successfully, it will emit a `PublicationEvaluationCompleted` event, with an evaluation result of `APPROVED` or `DECLINED`. This is what the Property Web will listen to in order to make the list available for publication. 23 | 24 | ## Note: 25 | 26 | Upon deleting the CloudFormation stack for this service, check if the `ApprovalStateMachine` StepFunction doesn't have any executions in `RUNNING` state. If there are, cancel those execution prior to deleting the CloudFormation stack. 27 | -------------------------------------------------------------------------------- /unicorn_properties/eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import eslint from "@eslint/js"; 2 | import tseslint from "typescript-eslint"; 3 | import prettierConfig from 'eslint-config-prettier' 4 | 5 | export default tseslint.config( 6 | { 7 | ignores: ["**/node_modules", "**/.aws-sam", "**/template.yaml", "**/cdk.out", "**/.github", "**/jest.config.js", "**/.prettierrc.js"], 8 | }, 9 | eslint.configs.recommended, 10 | tseslint.configs.stylistic, 11 | prettierConfig, 12 | { 13 | rules: { 14 | "no-unused-vars": "off", 15 | "@typescript-eslint/no-unused-vars": ["error"] 16 | } 17 | } 18 | ); -------------------------------------------------------------------------------- /unicorn_properties/integration/PublicationEvaluationCompleted.json: -------------------------------------------------------------------------------- 1 | { 2 | "openapi": "3.0.0", 3 | "info": { 4 | "version": "1.0.0", 5 | "title": "PublicationEvaluationCompleted" 6 | }, 7 | "paths": {}, 8 | "components": { 9 | "schemas": { 10 | "AWSEvent": { 11 | "type": "object", 12 | "required": [ 13 | "detail-type", 14 | "resources", 15 | "detail", 16 | "id", 17 | "source", 18 | "time", 19 | "region", 20 | "version", 21 | "account" 22 | ], 23 | "x-amazon-events-detail-type": "PublicationEvaluationCompleted", 24 | "x-amazon-events-source": "unicorn.web", 25 | "properties": { 26 | "detail": { 27 | "$ref": "#/components/schemas/PublicationEvaluationCompleted" 28 | }, 29 | "account": { 30 | "type": "string" 31 | }, 32 | "detail-type": { 33 | "type": "string" 34 | }, 35 | "id": { 36 | "type": "string" 37 | }, 38 | "region": { 39 | "type": "string" 40 | }, 41 | "resources": { 42 | "type": "array", 43 | "items": { 44 | "type": "string" 45 | } 46 | }, 47 | "source": { 48 | "type": "string" 49 | }, 50 | "time": { 51 | "type": "string", 52 | "format": "date-time" 53 | }, 54 | "version": { 55 | "type": "string" 56 | } 57 | } 58 | }, 59 | "PublicationEvaluationCompleted": { 60 | "type": "object", 61 | "required": [ 62 | "property_id", 63 | "evaluation_result" 64 | ], 65 | "properties": { 66 | "property_id": { 67 | "type": "string" 68 | }, 69 | "evaluation_result": { 70 | "type": "string" 71 | } 72 | } 73 | } 74 | } 75 | } 76 | } -------------------------------------------------------------------------------- /unicorn_properties/integration/event-schemas.yaml: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: MIT-0 3 | AWSTemplateFormatVersion: "2010-09-09" 4 | Description: Event Schemas for use by the Properties Service 5 | 6 | Parameters: 7 | Stage: 8 | Type: String 9 | Default: local 10 | AllowedValues: 11 | - local 12 | - dev 13 | - prod 14 | 15 | Resources: 16 | EventRegistry: 17 | Type: AWS::EventSchemas::Registry 18 | Properties: 19 | Description: 'Event schemas for Unicorn Properties' 20 | RegistryName: 21 | Fn::Sub: "{{resolve:ssm:/uni-prop/UnicornPropertiesNamespace}}-${Stage}" 22 | 23 | EventRegistryPolicy: 24 | Type: AWS::EventSchemas::RegistryPolicy 25 | Properties: 26 | RegistryName: 27 | Fn::GetAtt: EventRegistry.RegistryName 28 | Policy: 29 | Version: "2012-10-17" 30 | Statement: 31 | - Sid: AllowExternalServices 32 | Effect: Allow 33 | Principal: 34 | AWS: 35 | - Ref: AWS::AccountId 36 | Action: 37 | - schemas:DescribeCodeBinding 38 | - schemas:DescribeRegistry 39 | - schemas:DescribeSchema 40 | - schemas:GetCodeBindingSource 41 | - schemas:ListSchemas 42 | - schemas:ListSchemaVersions 43 | - schemas:SearchSchemas 44 | Resource: 45 | - Fn::GetAtt: EventRegistry.RegistryArn 46 | - Fn::Sub: "arn:${AWS::Partition}:schemas:${AWS::Region}:${AWS::AccountId}:schema/${EventRegistry.RegistryName}*" 47 | 48 | PublicationEvaluationCompleted: 49 | Type: AWS::EventSchemas::Schema 50 | Properties: 51 | Type: 'OpenApi3' 52 | RegistryName: 53 | Fn::GetAtt: EventRegistry.RegistryName 54 | SchemaName: 55 | Fn::Sub: '{{resolve:ssm:/uni-prop/UnicornPropertiesNamespace}}@PublicationEvaluationCompleted' 56 | Description: 'The schema for when a property evaluation is completed' 57 | Content: 58 | Fn::Sub: | 59 | { 60 | "openapi": "3.0.0", 61 | "info": { 62 | "version": "1.0.0", 63 | "title": "PublicationEvaluationCompleted" 64 | }, 65 | "paths": {}, 66 | "components": { 67 | "schemas": { 68 | "AWSEvent": { 69 | "type": "object", 70 | "required": [ 71 | "detail-type", 72 | "resources", 73 | "detail", 74 | "id", 75 | "source", 76 | "time", 77 | "region", 78 | "version", 79 | "account" 80 | ], 81 | "x-amazon-events-detail-type": "PublicationEvaluationCompleted", 82 | "x-amazon-events-source": "${EventRegistry.RegistryName}", 83 | "properties": { 84 | "detail": { 85 | "$ref": "#/components/schemas/PublicationEvaluationCompleted" 86 | }, 87 | "account": { 88 | "type": "string" 89 | }, 90 | "detail-type": { 91 | "type": "string" 92 | }, 93 | "id": { 94 | "type": "string" 95 | }, 96 | "region": { 97 | "type": "string" 98 | }, 99 | "resources": { 100 | "type": "array", 101 | "items": { 102 | "type": "string" 103 | } 104 | }, 105 | "source": { 106 | "type": "string" 107 | }, 108 | "time": { 109 | "type": "string", 110 | "format": "date-time" 111 | }, 112 | "version": { 113 | "type": "string" 114 | } 115 | } 116 | }, 117 | "PublicationEvaluationCompleted": { 118 | "type": "object", 119 | "required": [ 120 | "property_id", 121 | "evaluation_result" 122 | ], 123 | "properties": { 124 | "property_id": { 125 | "type": "string" 126 | }, 127 | "evaluation_result": { 128 | "type": "string" 129 | } 130 | } 131 | } 132 | } 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /unicorn_properties/integration/subscriber-policies.yaml: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: MIT-0 3 | AWSTemplateFormatVersion: "2010-09-09" 4 | Description: > 5 | Defines the event bus policies that determine who can create rules on the event bus to 6 | subscribe to events published by Unicorn Properties Service. 7 | 8 | Parameters: 9 | Stage: 10 | Type: String 11 | Default: local 12 | AllowedValues: 13 | - local 14 | - dev 15 | - prod 16 | 17 | Resources: 18 | # Update this policy as you get new subscribers by adding their namespace to events:source 19 | CrossServiceCreateRulePolicy: 20 | Type: AWS::Events::EventBusPolicy 21 | Properties: 22 | EventBusName: 23 | Fn::Sub: "{{resolve:ssm:/uni-prop/${Stage}/UnicornPropertiesEventBus}}" 24 | StatementId: 25 | Fn::Sub: "OnlyRulesForPropertiesServiceEvents-${Stage}" 26 | Statement: 27 | Effect: Allow 28 | Principal: 29 | AWS: 30 | Fn::Sub: "arn:${AWS::Partition}:iam::${AWS::AccountId}:root" 31 | Action: 32 | - events:PutRule 33 | - events:DeleteRule 34 | - events:DescribeRule 35 | - events:DisableRule 36 | - events:EnableRule 37 | - events:PutTargets 38 | - events:RemoveTargets 39 | Resource: 40 | - Fn::Sub: 41 | - arn:${AWS::Partition}:events:${AWS::Region}:${AWS::AccountId}:rule/${eventBusName}/* 42 | - eventBusName: 43 | Fn::Sub: "{{resolve:ssm:/uni-prop/${Stage}/UnicornPropertiesEventBus}}" 44 | Condition: 45 | StringEqualsIfExists: 46 | "events:creatorAccount": "${aws:PrincipalAccount}" 47 | StringEquals: 48 | "events:source": 49 | - "{{resolve:ssm:/uni-prop/UnicornPropertiesNamespace}}" 50 | "Null": 51 | "events:source": "false" 52 | -------------------------------------------------------------------------------- /unicorn_properties/integration/subscriptions.yaml: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: MIT-0 3 | AWSTemplateFormatVersion: "2010-09-09" 4 | Description: Defines the rule for the events (subscriptions) that Unicorn Properties wants to consume. 5 | 6 | Parameters: 7 | Stage: 8 | Type: String 9 | Default: local 10 | AllowedValues: 11 | - local 12 | - dev 13 | - prod 14 | 15 | Resources: 16 | 17 | #### UNICORN CONTRACTS EVENT SUBSCRIPTIONS 18 | ContractStatusChangedSubscriptionRule: 19 | Type: AWS::Events::Rule 20 | Properties: 21 | Name: unicorn.properties-ContractStatusChanged 22 | Description: Contract Status Changed subscription 23 | EventBusName: 24 | Fn::Sub: "{{resolve:ssm:/uni-prop/${Stage}/UnicornContractsEventBusArn}}" 25 | EventPattern: 26 | source: 27 | - "{{resolve:ssm:/uni-prop/UnicornContractsNamespace}}" 28 | detail-type: 29 | - ContractStatusChanged 30 | State: ENABLED 31 | Targets: 32 | - Id: SendEventTo 33 | Arn: 34 | Fn::Sub: "{{resolve:ssm:/uni-prop/${Stage}/UnicornPropertiesEventBusArn}}" 35 | RoleArn: 36 | Fn::GetAtt: [ UnicornPropertiesSubscriptionRole, Arn ] 37 | 38 | #### UNICORN WEB EVENT SUBSCRIPTIONS 39 | PublicationApprovalRequestedSubscriptionRule: 40 | Type: AWS::Events::Rule 41 | Properties: 42 | Name: unicorn.properties-PublicationApprovalRequested 43 | Description: Publication evaluation completed subscription 44 | EventBusName: 45 | Fn::Sub: "{{resolve:ssm:/uni-prop/${Stage}/UnicornWebEventBusArn}}" 46 | EventPattern: 47 | source: 48 | - "{{resolve:ssm:/uni-prop/UnicornWebNamespace}}" 49 | detail-type: 50 | - PublicationApprovalRequested 51 | State: ENABLED 52 | Targets: 53 | - Id: SendEventTo 54 | Arn: 55 | Fn::Sub: "{{resolve:ssm:/uni-prop/${Stage}/UnicornPropertiesEventBusArn}}" 56 | RoleArn: 57 | Fn::GetAtt: [ UnicornPropertiesSubscriptionRole, Arn ] 58 | 59 | 60 | # This IAM role allows EventBridge to assume the permissions necessary to send events 61 | # from the publishing event bus, to the subscribing event bus (UnicornPropertiesEventBusArn) 62 | UnicornPropertiesSubscriptionRole: 63 | Type: AWS::IAM::Role 64 | Properties: 65 | AssumeRolePolicyDocument: 66 | Statement: 67 | - Effect: Allow 68 | Action: sts:AssumeRole 69 | Principal: 70 | Service: events.amazonaws.com 71 | Policies: 72 | - PolicyName: PutEventsOnUnicornPropertiesEventBus 73 | PolicyDocument: 74 | Version: "2012-10-17" 75 | Statement: 76 | - Effect: Allow 77 | Action: events:PutEvents 78 | Resource: 79 | Fn::Sub: "{{resolve:ssm:/uni-prop/${Stage}/UnicornPropertiesEventBusArn}}" 80 | 81 | Outputs: 82 | ContractStatusChangedSubscription: 83 | Description: Rule ARN for Contract service event subscription 84 | Value: 85 | Fn::GetAtt: [ ContractStatusChangedSubscriptionRule, Arn ] 86 | 87 | PublicationApprovalRequestedSubscription: 88 | Description: Rule ARN for Web service event subscription 89 | Value: 90 | Fn::GetAtt: [ PublicationApprovalRequestedSubscriptionRule, Arn ] 91 | -------------------------------------------------------------------------------- /unicorn_properties/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: "ts-jest", 3 | transform: { 4 | "^.+\\.ts?$": "ts-jest", 5 | }, 6 | moduleFileExtensions: ["js", "ts"], 7 | collectCoverageFrom: ["**/src/**/*.ts", "!**/node_modules/**"], 8 | testMatch: ["**/tests/unit/*.test.ts", "**/tests/integration/*.test.ts"], 9 | testPathIgnorePatterns: ["/node_modules/"], 10 | testEnvironment: "node", 11 | coverageProvider: "v8", 12 | }; 13 | -------------------------------------------------------------------------------- /unicorn_properties/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "properties", 3 | "version": "1.0.0", 4 | "description": "Properties Module for Serverless Developer Experience Reference Architecture - Node", 5 | "repository": "https://github.com/aws-samples/aws-serverless-developer-experience-workshop-typescript/tree/develop", 6 | "license": "MIT", 7 | "author": "Amazon.com, Inc.", 8 | "main": "app.js", 9 | "scripts": { 10 | "build": "sam build", 11 | "compile": "tsc", 12 | "deploy": "sam build && sam deploy --no-confirm-changeset", 13 | "lint": "eslint --ext .ts --quiet --fix", 14 | "test": "jest --runInBand", 15 | "unit": "jest --config=jest.config.test.unit.ts" 16 | }, 17 | "dependencies": { 18 | "@aws-lambda-powertools/commons": "^2.20.0", 19 | "@aws-lambda-powertools/logger": "^2.20.0", 20 | "@aws-lambda-powertools/metrics": "^2.20.0", 21 | "@aws-lambda-powertools/tracer": "^2.20.0", 22 | "@aws-sdk/client-dynamodb": "^3.812.0", 23 | "@aws-sdk/client-sfn": "^3.812.0", 24 | "@aws-sdk/util-dynamodb": "^3.812.0", 25 | "aws-lambda": "^1.0.7" 26 | }, 27 | "devDependencies": { 28 | "@aws-sdk/client-cloudformation": "^3.812.0", 29 | "@aws-sdk/client-cloudwatch-logs": "^3.812.0", 30 | "@aws-sdk/client-eventbridge": "^3.812.0", 31 | "@aws-sdk/lib-dynamodb": "^3.814.0", 32 | "@types/aws-lambda": "^8.10.149", 33 | "@types/jest": "^29.5.14", 34 | "@types/node": "^22.15.21", 35 | "aws-sdk-client-mock": "^4.1.0", 36 | "esbuild": "^0.25.4", 37 | "esbuild-jest": "^0.5.0", 38 | "eslint": "^9.27.0", 39 | "eslint-config-prettier": "^10.1.5", 40 | "globals": "^16.1.0", 41 | "jest": "^29.7.0", 42 | "prettier": "^3.5.3", 43 | "ts-jest": "^29.3.4", 44 | "ts-node": "^10.9.2", 45 | "typescript": "^5.8.3", 46 | "typescript-eslint": "^8.32.1" 47 | } 48 | } -------------------------------------------------------------------------------- /unicorn_properties/samconfig.toml: -------------------------------------------------------------------------------- 1 | version = 0.1 2 | 3 | [default.global.parameters] 4 | stack_name = "uni-prop-local-properties" 5 | s3_prefix = "uni-prop-local-properties" 6 | resolve_s3 = true 7 | resolve_image_repositories = true 8 | 9 | [default.build.parameters] 10 | cached = true 11 | parallel = true 12 | 13 | [default.deploy.parameters] 14 | disable_rollback = true 15 | confirm_changeset = false 16 | fail_on_empty_changeset = false 17 | capabilities = ["CAPABILITY_IAM", "CAPABILITY_AUTO_EXPAND"] 18 | parameter_overrides = ["Stage=local"] 19 | 20 | [default.validate.parameters] 21 | lint = true 22 | 23 | [default.sync.parameters] 24 | watch = true 25 | 26 | [default.local_start_api.parameters] 27 | warm_containers = "EAGER" 28 | 29 | [default.local_start_lambda.parameters] 30 | warm_containers = "EAGER" -------------------------------------------------------------------------------- /unicorn_properties/src/properties_service/contractStatusChangedEventHandler.ts: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | import { EventBridgeEvent, Context } from 'aws-lambda'; 4 | import type { LambdaInterface } from '@aws-lambda-powertools/commons/types'; 5 | import { MetricUnit } from '@aws-lambda-powertools/metrics'; 6 | import { logger, metrics, tracer } from './powertools'; 7 | import { 8 | DynamoDBClient, 9 | UpdateItemCommand, 10 | UpdateItemCommandInput, 11 | UpdateItemCommandOutput, 12 | } from '@aws-sdk/client-dynamodb'; 13 | import { ContractStatusChanged } from '../schema/unicorn_contracts/contractstatuschanged/ContractStatusChanged'; 14 | import { Marshaller } from '../schema/unicorn_contracts/contractstatuschanged/marshaller/Marshaller'; 15 | 16 | // Empty configuration for DynamoDB 17 | const ddbClient = new DynamoDBClient({}); 18 | const DDB_TABLE = process.env.DYNAMODB_TABLE ?? 'ContractStatusTable'; 19 | 20 | export interface ContractStatusError extends Error { 21 | contract_id: string; 22 | name: string; 23 | object: any; 24 | } 25 | 26 | class ContractStatusChangedFunction implements LambdaInterface { 27 | /** 28 | * Handle the contract status changed event from the EventBridge instance. 29 | * @param {Object} event - EventBridge Event Input Format 30 | * @returns {void} 31 | * 32 | */ 33 | @tracer.captureLambdaHandler() 34 | @metrics.logMetrics({ captureColdStartMetric: true }) 35 | @logger.injectLambdaContext({ logEvent: true }) 36 | public async handler( 37 | event: EventBridgeEvent, 38 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 39 | context: Context 40 | ): Promise { 41 | logger.info(`Contract status changed: ${JSON.stringify(event.detail)}`); 42 | try { 43 | // Construct the entry to insert into database. 44 | const statusEntry: ContractStatusChanged = Marshaller.unmarshal( 45 | event.detail, 46 | 'ContractStatusChanged' 47 | ); 48 | tracer.putAnnotation('ContractStatus', statusEntry.contractStatus); 49 | logger.info(`Unmarshalled entry: ${JSON.stringify(statusEntry)}`); 50 | 51 | // Call saveContractStatus with the entry 52 | await this.saveContractStatus(statusEntry); 53 | } catch (error: any) { 54 | tracer.addErrorAsMetadata(error as Error); 55 | logger.error(`Error during DDB UPDATE: ${JSON.stringify(error)}`); 56 | } 57 | metrics.addMetric('ContractStatusChanged', MetricUnit.Count, 1); 58 | } 59 | 60 | /** 61 | * Update the ContractStatus entry in the database 62 | * @param statusEntry 63 | */ 64 | @tracer.captureMethod() 65 | private async saveContractStatus(statusEntry: ContractStatusChanged) { 66 | logger.info( 67 | `Updating status: ${statusEntry.contractStatus} for ${statusEntry.propertyId}` 68 | ); 69 | const ddbUpdateCommandInput: UpdateItemCommandInput = { 70 | TableName: DDB_TABLE, 71 | Key: { property_id: { S: statusEntry.propertyId } }, 72 | UpdateExpression: 73 | 'set contract_id = :c, contract_status = :t, contract_last_modified_on = :m', 74 | ExpressionAttributeValues: { 75 | ':c': { S: statusEntry.contractId as string }, 76 | ':t': { S: statusEntry.contractStatus as string }, 77 | ':m': { S: statusEntry.contractLastModifiedOn }, 78 | }, 79 | }; 80 | logger.info(`Constructed command ${JSON.stringify(ddbUpdateCommandInput)}`); 81 | const ddbUpdateCommand = new UpdateItemCommand(ddbUpdateCommandInput); 82 | 83 | // Send the command 84 | const ddbUpdateCommandOutput: UpdateItemCommandOutput = 85 | await ddbClient.send(ddbUpdateCommand); 86 | logger.info( 87 | `Updated status: ${statusEntry.contractStatus} for ${statusEntry.propertyId}` 88 | ); 89 | if (ddbUpdateCommandOutput.$metadata.httpStatusCode != 200) { 90 | const error: ContractStatusError = { 91 | contract_id: statusEntry.contractId, 92 | name: 'ContractStatusDBUpdateError', 93 | message: 94 | 'Response error code: ' + 95 | ddbUpdateCommandOutput.$metadata.httpStatusCode, 96 | object: ddbUpdateCommandOutput.$metadata, 97 | }; 98 | throw error; 99 | } 100 | } 101 | } 102 | 103 | export const myFunction = new ContractStatusChangedFunction(); 104 | export const lambdaHandler = myFunction.handler.bind(myFunction); 105 | -------------------------------------------------------------------------------- /unicorn_properties/src/properties_service/powertools.ts: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | import { Metrics } from '@aws-lambda-powertools/metrics'; 4 | import { Logger } from '@aws-lambda-powertools/logger'; 5 | import type { LogLevel } from '@aws-lambda-powertools/logger/types'; 6 | import { Tracer } from '@aws-lambda-powertools/tracer'; 7 | 8 | const SERVICE_NAMESPACE = process.env.SERVICE_NAMESPACE ?? 'test_namespace'; 9 | 10 | const defaultValues = { 11 | region: process.env.AWS_REGION || 'N/A', 12 | executionEnv: process.env.AWS_EXECUTION_ENV || 'N/A', 13 | }; 14 | 15 | const logger = new Logger({ 16 | logLevel: (process.env.LOG_LEVEL || 'INFO') as LogLevel, 17 | persistentLogAttributes: { 18 | ...defaultValues, 19 | logger: { 20 | name: '@aws-lambda-powertools/logger', 21 | }, 22 | }, 23 | }); 24 | 25 | const metrics = new Metrics({ 26 | defaultDimensions: defaultValues, 27 | namespace: SERVICE_NAMESPACE, 28 | }); 29 | 30 | const tracer = new Tracer(); 31 | 32 | export { metrics, logger, tracer }; 33 | -------------------------------------------------------------------------------- /unicorn_properties/src/schema/unicorn_contracts/contractstatuschanged/AWSEvent.ts: -------------------------------------------------------------------------------- 1 | export class AWSEvent { 2 | 'detail': T; 3 | 'detail_type': string; 4 | 'resources': string[]; 5 | 'id': string; 6 | 'source': string; 7 | 'time': Date; 8 | 'region': string; 9 | 'version': string; 10 | 'account': string; 11 | 12 | static discriminator: string | undefined = undefined; 13 | 14 | static detail = 'detail'; 15 | 16 | static genericType = 'T'; 17 | 18 | static attributeTypeMap: { 19 | name: string; 20 | baseName: string; 21 | type: string; 22 | }[] = [ 23 | { 24 | name: AWSEvent.detail, 25 | baseName: AWSEvent.detail, 26 | type: AWSEvent.genericType, 27 | }, 28 | { 29 | name: 'detail_type', 30 | baseName: 'detail-type', 31 | type: 'string', 32 | }, 33 | { 34 | name: 'resources', 35 | baseName: 'resources', 36 | type: 'Array', 37 | }, 38 | { 39 | name: 'id', 40 | baseName: 'id', 41 | type: 'string', 42 | }, 43 | { 44 | name: 'source', 45 | baseName: 'source', 46 | type: 'string', 47 | }, 48 | { 49 | name: 'time', 50 | baseName: 'time', 51 | type: 'Date', 52 | }, 53 | { 54 | name: 'region', 55 | baseName: 'region', 56 | type: 'string', 57 | }, 58 | { 59 | name: 'version', 60 | baseName: 'version', 61 | type: 'string', 62 | }, 63 | { 64 | name: 'account', 65 | baseName: 'account', 66 | type: 'string', 67 | }, 68 | ]; 69 | 70 | public static getAttributeTypeMap() { 71 | return AWSEvent.attributeTypeMap; 72 | } 73 | 74 | public static updateAttributeTypeMapDetail(type: string) { 75 | const index = AWSEvent.attributeTypeMap.indexOf({ 76 | name: AWSEvent.detail, 77 | baseName: AWSEvent.detail, 78 | type: AWSEvent.genericType, 79 | }); 80 | this.attributeTypeMap[index] = { 81 | name: AWSEvent.detail, 82 | baseName: AWSEvent.detail, 83 | type, 84 | }; 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /unicorn_properties/src/schema/unicorn_contracts/contractstatuschanged/ContractStatusChanged.ts: -------------------------------------------------------------------------------- 1 | export class ContractStatusChanged { 2 | 'contractId': string; 3 | 'contractLastModifiedOn': string; 4 | 'contractStatus': string; 5 | 'propertyId': string; 6 | 7 | private static discriminator: string | undefined = undefined; 8 | 9 | private static attributeTypeMap: { 10 | name: string; 11 | baseName: string; 12 | type: string; 13 | }[] = [ 14 | { 15 | name: 'contractId', 16 | baseName: 'contract_id', 17 | type: 'string', 18 | }, 19 | { 20 | name: 'contractLastModifiedOn', 21 | baseName: 'contract_last_modified_on', 22 | type: 'string', 23 | }, 24 | { 25 | name: 'contractStatus', 26 | baseName: 'contract_status', 27 | type: 'string', 28 | }, 29 | { 30 | name: 'propertyId', 31 | baseName: 'property_id', 32 | type: 'string', 33 | }, 34 | ]; 35 | 36 | public static getAttributeTypeMap() { 37 | return ContractStatusChanged.attributeTypeMap; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /unicorn_properties/src/schema/unicorn_contracts/contractstatuschanged/marshaller/Marshaller.ts: -------------------------------------------------------------------------------- 1 | import { AWSEvent } from '../AWSEvent'; 2 | import { ContractStatusChanged } from '../ContractStatusChanged'; 3 | 4 | const primitives = [ 5 | 'string', 6 | 'boolean', 7 | 'double', 8 | 'integer', 9 | 'long', 10 | 'float', 11 | 'number', 12 | 'any', 13 | ]; 14 | 15 | const enumsMap: Record = {}; 16 | 17 | const typeMap: Record = { 18 | AWSEvent: AWSEvent, 19 | ContractStatusChanged: ContractStatusChanged, 20 | }; 21 | 22 | export class Marshaller { 23 | public static marshall(data: any, type: string) { 24 | if (data == undefined) { 25 | return data; 26 | } else if (primitives.indexOf(type.toLowerCase()) !== -1) { 27 | return data; 28 | } else if (type.lastIndexOf('Array<', 0) === 0) { 29 | // string.startsWith pre es6 30 | let subType: string = type.replace('Array<', ''); // Array => Type> 31 | subType = subType.substring(0, subType.length - 1); // Type> => Type 32 | const transformedData: any[] = []; 33 | for (const index in data) { 34 | const date = data[index]; 35 | transformedData.push(Marshaller.marshall(date, subType)); 36 | } 37 | return transformedData; 38 | } else if (type === 'Date') { 39 | return data.toString(); 40 | } else { 41 | if (enumsMap[type]) { 42 | return data; 43 | } 44 | if (!typeMap[type]) { 45 | // in case we dont know the type 46 | return data; 47 | } 48 | 49 | // get the map for the correct type. 50 | const attributeTypes = typeMap[type].getAttributeTypeMap(); 51 | const instance: Record = {}; 52 | for (const index in attributeTypes) { 53 | const attributeType = attributeTypes[index]; 54 | instance[attributeType.baseName] = Marshaller.marshall( 55 | data[attributeType.name], 56 | attributeType.type 57 | ); 58 | } 59 | return instance; 60 | } 61 | } 62 | 63 | public static unmarshalEvent(data: any, detailType: any) { 64 | typeMap['AWSEvent'].updateAttributeTypeMapDetail(detailType.name); 65 | return this.unmarshal(data, 'AWSEvent'); 66 | } 67 | 68 | public static unmarshal(data: any, type: string) { 69 | // polymorphism may change the actual type. 70 | type = Marshaller.findCorrectType(data, type); 71 | if (data == undefined) { 72 | return data; 73 | } else if (primitives.indexOf(type.toLowerCase()) !== -1) { 74 | return data; 75 | } else if (type.lastIndexOf('Array<', 0) === 0) { 76 | // string.startsWith pre es6 77 | let subType: string = type.replace('Array<', ''); // Array => Type> 78 | subType = subType.substring(0, subType.length - 1); // Type> => Type 79 | const transformedData: any[] = []; 80 | for (const index in data) { 81 | const date = data[index]; 82 | transformedData.push(Marshaller.unmarshal(date, subType)); 83 | } 84 | return transformedData; 85 | } else if (type === 'Date') { 86 | return new Date(data); 87 | } else { 88 | if (enumsMap[type]) { 89 | // is Enum 90 | return data; 91 | } 92 | 93 | if (!typeMap[type]) { 94 | // dont know the type 95 | return data; 96 | } 97 | const instance = new typeMap[type](); 98 | const attributeTypes = typeMap[type].getAttributeTypeMap(); 99 | for (const index in attributeTypes) { 100 | const attributeType = attributeTypes[index]; 101 | instance[attributeType.name] = Marshaller.unmarshal( 102 | data[attributeType.baseName], 103 | attributeType.type 104 | ); 105 | } 106 | return instance; 107 | } 108 | } 109 | 110 | private static findCorrectType(data: any, expectedType: string) { 111 | if (data == undefined) { 112 | return expectedType; 113 | } else if (primitives.indexOf(expectedType.toLowerCase()) !== -1) { 114 | return expectedType; 115 | } else if (expectedType === 'Date') { 116 | return expectedType; 117 | } else { 118 | if (enumsMap[expectedType]) { 119 | return expectedType; 120 | } 121 | 122 | if (!typeMap[expectedType]) { 123 | return expectedType; // unknown type 124 | } 125 | 126 | // Check the discriminator 127 | const discriminatorProperty = typeMap[expectedType].discriminator; 128 | if (discriminatorProperty == null) { 129 | return expectedType; // the type does not have a discriminator. use it. 130 | } else { 131 | if (data[discriminatorProperty]) { 132 | return data[discriminatorProperty]; // use the type given in the discriminator 133 | } else { 134 | return expectedType; // discriminator was not present (or an empty string) 135 | } 136 | } 137 | } 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /unicorn_properties/src/state_machine/property_approval.asl.yaml: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: MIT-0 3 | Comment: The property approval workflow ensures that its images and content is 4 | safe to publish and that there is an approved contract in place before the 5 | listing is made available to the public through the Unicorn Properties 6 | website. 7 | QueryLanguage: JSONata 8 | StartAt: LookupContract 9 | States: 10 | LookupContract: 11 | Type: Task 12 | Resource: arn:aws:states:::dynamodb:getItem 13 | Arguments: 14 | TableName: ${TableName} 15 | Key: 16 | property_id: 17 | S: "{% $states.input.detail.property_id %}" 18 | ProjectionExpression: contract_id, property_id, contract_status 19 | Next: VerifyContractExists 20 | Catch: 21 | - ErrorEquals: 22 | - States.ALL 23 | Next: NotFound 24 | VerifyContractExists: 25 | Type: Choice 26 | Choices: 27 | - Comment: Contract Exists 28 | Condition: "{% $exists($states.input.Item) %}" 29 | Next: Parallel 30 | Default: NotFound 31 | Comment: No Contract Found 32 | Parallel: 33 | Type: Parallel 34 | Next: IsContentSafe 35 | Branches: 36 | - StartAt: CheckDescriptionSentiment 37 | States: 38 | CheckDescriptionSentiment: 39 | Type: Task 40 | Resource: arn:aws:states:::aws-sdk:comprehend:detectSentiment 41 | Arguments: 42 | LanguageCode: en 43 | Text: "{% $states.context.Execution.Input.detail.description %}" 44 | End: true 45 | - StartAt: Map 46 | States: 47 | Map: 48 | Type: Map 49 | Items: "{% $states.context.Execution.Input.detail.images %}" 50 | ItemProcessor: 51 | ProcessorConfig: 52 | Mode: INLINE 53 | StartAt: CheckForUnsafeContentInImages 54 | States: 55 | CheckForUnsafeContentInImages: 56 | Type: Task 57 | Resource: arn:aws:states:::aws-sdk:rekognition:detectModerationLabels 58 | Arguments: 59 | Image: 60 | S3Object: 61 | Bucket: ${ImageUploadBucketName} 62 | Name: "{% $states.input %}" 63 | End: true 64 | End: true 65 | IsContentSafe: 66 | Type: Choice 67 | Choices: 68 | - Comment: ContentModerationPassed 69 | Condition: "{% $states.input[0].Sentiment = 'POSITIVE' and 70 | $not($exists($states.input[1].ModerationLabels.*)) %}" 71 | Next: WaitForContractApproval 72 | Default: PublishPropertyPublicationRejected 73 | WaitForContractApproval: 74 | Type: Task 75 | Resource: arn:aws:states:::lambda:invoke.waitForTaskToken 76 | Comment: ContractStatusChecker 77 | Arguments: 78 | FunctionName: ${WaitForContractApprovalArn} 79 | Payload: 80 | Input: "{% $states.context.Execution.Input.detail %}" 81 | TaskToken: "{% $states.context.Task.Token %}" 82 | Retry: 83 | - ErrorEquals: 84 | - Lambda.ServiceException 85 | - Lambda.AWSLambdaException 86 | - Lambda.SdkClientException 87 | - Lambda.TooManyRequestsException 88 | IntervalSeconds: 1 89 | MaxAttempts: 3 90 | BackoffRate: 2 91 | JitterStrategy: FULL 92 | Output: "{% $states.context.Execution.Input %}" 93 | Next: PublishPropertyPublicationApproved 94 | PublishPropertyPublicationApproved: 95 | Type: Task 96 | Resource: arn:aws:states:::events:putEvents 97 | Arguments: 98 | Entries: 99 | - EventBusName: ${EventBusName} 100 | Source: ${ServiceName} 101 | Detail: 102 | property_id: "{% $states.context.Execution.Input.detail.property_id %}" 103 | evaluation_result: APPROVED 104 | DetailType: PublicationEvaluationCompleted 105 | Next: Approved 106 | PublishPropertyPublicationRejected: 107 | Type: Task 108 | Resource: arn:aws:states:::events:putEvents 109 | Arguments: 110 | Entries: 111 | - EventBusName: ${EventBusName} 112 | Source: ${ServiceName} 113 | Detail: 114 | property_id: "{% $states.context.Execution.Input.detail.property_id %}" 115 | evaluation_result: DECLINED 116 | DetailType: PublicationEvaluationCompleted 117 | Next: Declined 118 | Declined: 119 | Type: Succeed 120 | Approved: 121 | Type: Succeed 122 | NotFound: 123 | Type: Fail 124 | -------------------------------------------------------------------------------- /unicorn_properties/tests/events/dbb_stream_events/contract_status_changed_draft.json: -------------------------------------------------------------------------------- 1 | { 2 | "Records": [ 3 | { 4 | "eventID": "70283d3d4dcfa99d38276979fd1e3f1f", 5 | "eventName": "INSERT", 6 | "eventVersion": "1.1", 7 | "eventSource": "aws:dynamodb", 8 | "awsRegion": "ap-southeast-2", 9 | "dynamodb": { 10 | "ApproximateCreationDateTime": 1661269906.0, 11 | "Keys": { "property_id": { "S": "usa/anytown/main-street/999" } }, 12 | "NewImage": { 13 | "contract_last_modified_on": { "S": "23/08/2022 15:51:44" }, 14 | "contract_id": { "S": "b9c03b51-5b8c-47c8-9d76-223679cde774" }, 15 | "contract_status": { "S": "DRAFT" }, 16 | "property_id": { "S": "usa/anytown/main-street/999" } 17 | }, 18 | "SequenceNumber": "100000000005391461882", 19 | "SizeBytes": 187, 20 | "StreamViewType": "NEW_AND_OLD_IMAGES" 21 | }, 22 | "eventSourceARN": "arn:aws:dynamodb:ap-southeast-2:123456789012:table/aws-unicorn-properties-properties-service-local-ContractStatusTable-GPGCVR510KFA/stream/2022-08-23T15:46:44.107" 23 | } 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /unicorn_properties/tests/events/dbb_stream_events/sfn_check_exists.json: -------------------------------------------------------------------------------- 1 | { 2 | "Records": [ 3 | { 4 | "eventID": "1dca9e1e9538380e4ef763e3d18bc2e4", 5 | "eventName": "MODIFY", 6 | "eventVersion": "1.1", 7 | "eventSource": "aws:dynamodb", 8 | "awsRegion": "ap-southeast-2", 9 | "dynamodb": { 10 | "ApproximateCreationDateTime": 1661312354.0, 11 | "Keys": { "property_id": { "S": "usa/anytown/main-street/999" } }, 12 | "NewImage": { 13 | "contract_last_modified_on": { "S": "24/08/2022 03:37:28" }, 14 | "contract_id": { "S": "418b32e7-87c4-40d2-a551-4d7a56830905" }, 15 | "contract_status": { "S": "DRAFT" }, 16 | "property_id": { "S": "usa/anytown/main-street/999" }, 17 | "sfn_contract_exists_task_token": { 18 | "S": "AQDIAAAAKgAAAAMAAAAAAAAAASxHOzYwUsA8mg9FgpS7TiMpXEm01Dro6ACdTxRxl9d8dCtnuHT1deXjaAkkzwh2ytaUpAjwWWcGgjA/pQUbzhdSBhE/oRx7ASRUNd4weldA53bOnM4TNL4y/k6nfPAsrnFiL++wyk6ERt/VSUsquBM6Mm1Kw+/9r6QqmmVkWoBfg/+KVA==sWAo4hixm0jircgkrfnKH5o4lPGXsHZz1AuALF0Lvy70waNMfwgmCz0PXejS+PcXEHLJ0eA9/IuBlhOfUQeHzv5hdMj9jh1n62k387CytHq5LPPTuqsLHizplCsapv3HYiHb8CZmeEHNVD0jBOZvUPx28v1ERDp4UkVAm1Kilxyt30ORmMZwZ7A9ucMRpMxjmK0uPUjP35yvbXU/z9R9sYAAK/e01mTnl1O5G5v7Fy/0qpGFYlMbONcl/+DWKwjysYLs33/CSuRaKQFsd5a9LTH5VrAM7lzvHNvbnb+ZeAIfMWz7clVJyo1f4kaqpHafQf+4B3TQOdDsb/ASpu2QYsw6o24PMUoaZQ67fST/tAEsAHm1h41L0YhWwpC/dWlHReUABQ3PKd+wT1VPs3wpW/PzwRddwzB6laFvN38YU5sTU7widqGY14OvN5W9vzb4iaFtdUmbfMWxinML0sRBSFUQHWmCYDnOliIR36sq4T6GkxuNVue7RWlhDbDLQu9SlM00nSxg/9dbcl4wAwxH" 19 | } 20 | }, 21 | "OldImage": { 22 | "contract_last_modified_on": { "S": "24/08/2022 03:37:28" }, 23 | "contract_id": { "S": "418b32e7-87c4-40d2-a551-4d7a56830905" }, 24 | "contract_status": { "S": "DRAFT" }, 25 | "property_id": { "S": "usa/anytown/main-street/999" } 26 | }, 27 | "SequenceNumber": "2360500000000006677219691", 28 | "SizeBytes": 1102, 29 | "StreamViewType": "NEW_AND_OLD_IMAGES" 30 | }, 31 | "eventSourceARN": "arn:aws:dynamodb:ap-southeast-2:123456789012:table/aws-unicorn-properties-properties-service-local-ContractStatusTable-GPGCVR510KFA/stream/2022-08-23T15:46:44.107" 32 | } 33 | ] 34 | } 35 | -------------------------------------------------------------------------------- /unicorn_properties/tests/events/dbb_stream_events/sfn_wait_approval.json: -------------------------------------------------------------------------------- 1 | { 2 | "Records": [ 3 | { 4 | "eventID": "eb388b2e39dc9da93600f611374f588b", 5 | "eventName": "MODIFY", 6 | "eventVersion": "1.1", 7 | "eventSource": "aws:dynamodb", 8 | "awsRegion": "ap-southeast-2", 9 | "dynamodb": { 10 | "ApproximateCreationDateTime": 1661345617.0, 11 | "Keys": { "property_id": { "S": "usa/anytown/main-street/999" } }, 12 | "NewImage": { 13 | "contract_last_modified_on": { "S": "24/08/2022 08:09:11" }, 14 | "contract_id": { "S": "3a574672-a87f-4150-8286-506cfeea4913" }, 15 | "contract_status": { "S": "DRAFT" }, 16 | "sfn_verify_approved_task_token": { 17 | "S": "AQDIAAAAKgAAAAMAAAAAAAAAATHiOzW3P9kS+UUCeOLc19jKIJ1NMlo4NoiGqkf5yt+P6Wv6DNHN71Sxsq0MSHeOfmjJEKHgVOgs55fI/cgvjAeesz/iWJvOrv7va2bHQuRMgSFT1J8HfMsA8O4TRTyv/yt14N+w0rSBuJLgJjOil23JGXjoh/989KvkDwwIIRVJJ6uHEw==/hCsUZVkaUn3WfnF4amPTo/9lWL3cG3HiDDFQgMH3owU0HbDV3vd3IlxMK0z73Y9D9oojAfwO1GrxPfPq/THtQvX15dlIOvEyPyiKwNo0y2kxuwoJDrwWpY/4NNbNEwOGIagivrQG2GURD89jLtf7FQUrryDIMadvx3bpfXIzRj5hRjArTR7sEhOWfiV1JPT5ReJvkxi++W3I9zAY+cdh4DYtnfZd2wXP5Di1/VBgz+TOviQEeeZFeg8tEM3S/xDpiRdSWCSiKFeY2I6jFgFzywhrCrDMWkt08eB1aZhxQuvMDbt4+3vBeIYoYhozOPI0Q8JAfOhIJ3EewNRFXbnrhGPDoctnE2tMQ5y8n8xTrp/DGRSEZ3IDRyxI6w1j8oyCsbqaEAB0d8jpXOaM4yuxaijbzU8czw6JjA6c+N2DIl3eHsl5OUcat/+298HdoFUo9BiICbEowAZePNh2uu/8CkAqam3h68JydNW6nZA3fo6agSBL9IFe4xGFU79Lu8AMPhIehKWuWPVeRsnqaUx" 18 | }, 19 | "property_id": { "S": "usa/anytown/main-street/999" }, 20 | "sfn_contract_exists_task_token": { 21 | "S": "AQDIAAAAKgAAAAMAAAAAAAAAAVS9BUaG0YfI6XbiFBynBEemcfQshPfHZqpHkUSL5AI0H54qZ95l2VRQ17BIZuhBNSfJyNLryGJFYq3Ro80CJcn5NePgWmpZt6Tx0SxZOtTaqU9Ozksy3p56Kg0uMABO1vmm5WJj7mnaaFmGwCl10+40Mrnmhqh6Q49BqcTZ1Ud92+NrPA==Z7mKaWIDnW9z8IviYx1YsRNkwpmKHwECnyFLgODmLEnwv9HKJCdKZV27GdM/A+C7X2OYyCei4fp6VG1l4qMVwukmt6nTc7vdTBxCV32C+mGSmhOWiK/qxRTxRKukLmrf5ahlgE/JDj8vHhptOVyqXNPzh3cYDURW/WMNR6EP+k9s1vsPvgOOivwMYkVAN0+Xpu4HV9gdSw0OqDAHScZUjVfvXxPVSnUrrvEpeUUflAUafO2Z+mdzCl173qTzd0povZXdkTKyMmdlILI6sZhuahnkuDcGD6Pnoq7XL/i3bDDGR90LCqxWnTorkec0HBNVct4/RltQnvRY0zASG6N9LDpXOy0qLY9ypgbWU4vEnTxOP0j3QYJjA8wO2wih45T3JRKGZ4N6U22WOEUwhePCyvr6oDBDel0cOS7nZE1fP3gn7k42ssnxIBGGzIsXp39HbnDkp8b4a4MNHmZ6tPcySyx9A5bokRG3XAaZfHZgqcUTSyCRCcbOxANkPxSvJADVHC1by8mp/j6tw202kXBD" 22 | } 23 | }, 24 | "OldImage": { 25 | "contract_last_modified_on": { "S": "24/08/2022 08:09:11" }, 26 | "contract_id": { "S": "3a574672-a87f-4150-8286-506cfeea4913" }, 27 | "contract_status": { "S": "DRAFT" }, 28 | "property_id": { "S": "usa/anytown/main-street/999" }, 29 | "sfn_contract_exists_task_token": { 30 | "S": "AQDIAAAAKgAAAAMAAAAAAAAAAVS9BUaG0YfI6XbiFBynBEemcfQshPfHZqpHkUSL5AI0H54qZ95l2VRQ17BIZuhBNSfJyNLryGJFYq3Ro80CJcn5NePgWmpZt6Tx0SxZOtTaqU9Ozksy3p56Kg0uMABO1vmm5WJj7mnaaFmGwCl10+40Mrnmhqh6Q49BqcTZ1Ud92+NrPA==Z7mKaWIDnW9z8IviYx1YsRNkwpmKHwECnyFLgODmLEnwv9HKJCdKZV27GdM/A+C7X2OYyCei4fp6VG1l4qMVwukmt6nTc7vdTBxCV32C+mGSmhOWiK/qxRTxRKukLmrf5ahlgE/JDj8vHhptOVyqXNPzh3cYDURW/WMNR6EP+k9s1vsPvgOOivwMYkVAN0+Xpu4HV9gdSw0OqDAHScZUjVfvXxPVSnUrrvEpeUUflAUafO2Z+mdzCl173qTzd0povZXdkTKyMmdlILI6sZhuahnkuDcGD6Pnoq7XL/i3bDDGR90LCqxWnTorkec0HBNVct4/RltQnvRY0zASG6N9LDpXOy0qLY9ypgbWU4vEnTxOP0j3QYJjA8wO2wih45T3JRKGZ4N6U22WOEUwhePCyvr6oDBDel0cOS7nZE1fP3gn7k42ssnxIBGGzIsXp39HbnDkp8b4a4MNHmZ6tPcySyx9A5bokRG3XAaZfHZgqcUTSyCRCcbOxANkPxSvJADVHC1by8mp/j6tw202kXBD" 31 | } 32 | }, 33 | "SequenceNumber": "4068300000000009582749593", 34 | "SizeBytes": 2634, 35 | "StreamViewType": "NEW_AND_OLD_IMAGES" 36 | }, 37 | "eventSourceARN": "arn:aws:dynamodb:ap-southeast-2:123456789012:table/aws-unicorn-properties-properties-service-local-ContractStatusTable-GPGCVR510KFA/stream/2022-08-23T15:46:44.107" 38 | } 39 | ] 40 | } 41 | -------------------------------------------------------------------------------- /unicorn_properties/tests/events/dbb_stream_events/status_approved_waiting_for_approval.json: -------------------------------------------------------------------------------- 1 | { 2 | "Records": [ 3 | { 4 | "eventID": "ce16edd66e3812941e0d721e877c752a", 5 | "eventName": "MODIFY", 6 | "eventVersion": "1.1", 7 | "eventSource": "aws:dynamodb", 8 | "awsRegion": "ap-southeast-2", 9 | "dynamodb": { 10 | "ApproximateCreationDateTime": 1661391844, 11 | "Keys": { 12 | "property_id": { 13 | "S": "usa/anytown/main-street/111" 14 | } 15 | }, 16 | "NewImage": { 17 | "contract_last_modified_on": { 18 | "S": "25/08/2022 01:44:02" 19 | }, 20 | "contract_id": { 21 | "S": "ef3f01a5-79ec-411a-b59a-7dbb19208ead" 22 | }, 23 | "contract_status": { 24 | "S": "APPROVED" 25 | }, 26 | "property_id": { 27 | "S": "usa/anytown/main-street/111" 28 | } 29 | }, 30 | "OldImage": { 31 | "contract_last_modified_on": { 32 | "S": "24/08/2022 15:53:26" 33 | }, 34 | "sfn_wait_approved_task_token": { 35 | "S": "AQDIAAAAKgAAAAMAAAAAAAAAAapnwdMR3Z7RAg3IavSq2hbHt+CZPIQYaakFO6Em9Ik00VsGcaznxotIUGB2t7kihvuu/ffeoF+Z4yg4dggTxtzVwvRJwUDQr33/s/LhJyvEfNS57PXCv/CYFssJZ+28FRCAYbGekKaopYhaUlvq0taLGMaEfIbRBeLUmLHInDkPPDbwTA==N0LRrCP3bIFW1MPkMQC3kd35p8yflvpThHCeviqe3qeyKBX03+ziocuyvNHVpktMuECnHL3MN9a6BfpSM1KItYI+qdIC74ls83ALSjjOs8G8pOz4OudcliLYAvmZPRQXvFaw6aSMLfJJ7xRpEFSBwj1zDzbadMLtfudG78pmZX1m75/idMU5gz0UPkC87bVQN6Kyjl7obxAeUO4aoqGJeNz6WbJQtrsUhiQWVEH/AwjUAj9Q0DwRqRPqeWyrv4MIMKea/Xu9vhXbcS+zPWPq8onN9fyAqoMNh64K6wSGWxtAbaaByKxNpQu7o9ho/Iu/ME0KOAqyUK6vcnXOpIwIoMAZiG34KF4UnQsD0gIokQcGLbehMGRixvEJMDZIloLxkuH0jvpIvD5xokGxpHwiVMISi2XRJ92nnGmWTCLWqsqJlsg4We6snJp0Akw2w1Nt41lgY8kWkjxHNWEHDIMjzx1zeWiVa9b9aDDcckb71ouknJCN4gbxVs+yP30M97qnCEmMrc25Yq7cEXLhu5Dh" 36 | }, 37 | "contract_id": { 38 | "S": "ef3f01a5-79ec-411a-b59a-7dbb19208ead" 39 | }, 40 | "contract_status": { 41 | "S": "DRAFT" 42 | }, 43 | "property_id": { 44 | "S": "usa/anytown/main-street/111" 45 | }, 46 | "sfn_contract_exists_task_token": { 47 | "S": "AQDIAAAAKgAAAAMAAAAAAAAAAfL0WOHwLPeUsqktBZSKrsN4dJqIHsLoClU1UUkV+fstVYTXYkMNsmLNyfmrWPVOpQrtK0JKSw+SmuA0qq5qIx/bwz10JfWY7EUB8BHSW6AeYPdzOwHUDqSLs+OzCBl4HhqbeLWCadXOrXRylY5N6P0QOmjLUN/08v+8Ioz2DvKqa3pUbQ==InWjQiaSHWHtJgpe+1IxIipKGyk0vg+LqvlIKRiJENbE1Hz2zZK3aROztTCnHISR2rYBjTDihcn3S6QTqRrlhLvlkxBrxIIzz8UB4v4PaPLklUFPV8IEF0JpT4uhpRST2QV7RuHBZWgQOcMRpM+0yvHZ5Pq4YNzJHKpkvEW34Kv4wsduWbYG3Wl6Yej5TvAHNpoqi3PS+Z2/cih/EDQmTiznmwLPptnA0wc1RrhShT7Izm2hR/lINW9LcY9346oyxEK2riWm9v9kxMB8vg926nqdIKwwDNCsMrpQGgOsIfW+ECQNPTefuPWIwZ4ycvzWeEP6mdhZ+pusD4+XUDIZ5Jjok74YIxqHE2nSck+kLyqpXxcxBc6CRX+SUSCUuWyWEpjYx43qAfTRgLhm1r+DlI5sg1ARHe0oVUoeuvV7jX2b9AHarMEiPUxvTGqn/lRM9c8x55TskXxF9EE7AV0hCzCC+DiT3nj70LZ/7iSJZpyORH3B3KEEJQA/7BtE4LYPVWmIhbVjXr0mf67zVl6Y" 48 | } 49 | }, 50 | "SequenceNumber": "6547000000000005631781392", 51 | "SizeBytes": 1869, 52 | "StreamViewType": "NEW_AND_OLD_IMAGES" 53 | }, 54 | "eventSourceARN": "arn:aws:dynamodb:ap-southeast-2:123456789012:table/aws-unicorn-properties-properties-service-local-ContractStatusTable-GPGCVR510KFA/stream/2022-08-23T15:46:44.107" 55 | } 56 | ] 57 | } 58 | -------------------------------------------------------------------------------- /unicorn_properties/tests/events/dbb_stream_events/status_approved_with_no_workflow.json: -------------------------------------------------------------------------------- 1 | { 2 | "Records": [ 3 | { 4 | "eventID": "8178a46079764b693e00ef96c1f6cfa6", 5 | "eventName": "MODIFY", 6 | "eventVersion": "1.1", 7 | "eventSource": "aws:dynamodb", 8 | "awsRegion": "ap-southeast-2", 9 | "dynamodb": { 10 | "ApproximateCreationDateTime": 1661270661.0, 11 | "Keys": { "property_id": { "S": "usa/anytown/main-street/999" } }, 12 | "NewImage": { 13 | "contract_last_modified_on": { "S": "23/08/2022 16:04:19" }, 14 | "contract_id": { "S": "b9c03b51-5b8c-47c8-9d76-223679cde774" }, 15 | "contract_status": { "S": "APPROVED" }, 16 | "property_id": { "S": "usa/anytown/main-street/999" } 17 | }, 18 | "OldImage": { 19 | "contract_last_modified_on": { "S": "23/08/2022 15:51:44" }, 20 | "contract_id": { "S": "b9c03b51-5b8c-47c8-9d76-223679cde774" }, 21 | "contract_status": { "S": "DRAFT" }, 22 | "property_id": { "S": "usa/anytown/main-street/999" } 23 | }, 24 | "SequenceNumber": "200000000005392276079", 25 | "SizeBytes": 339, 26 | "StreamViewType": "NEW_AND_OLD_IMAGES" 27 | }, 28 | "eventSourceARN": "arn:aws:dynamodb:ap-southeast-2:123456789012:table/aws-unicorn-properties-properties-service-local-ContractStatusTable-GPGCVR510KFA/stream/2022-08-23T15:46:44.107" 29 | } 30 | ] 31 | } 32 | -------------------------------------------------------------------------------- /unicorn_properties/tests/events/eventbridge/contract_status_changed_event_contract_1_approved.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "DetailType": "ContractStatusChanged", 4 | "Source": "unicorn.contracts", 5 | "EventBusName": "UnicornPropertiesBus-local", 6 | "Detail": "{ \"contract_last_modified_on\": \"10/08/2022 19:56:30\", \"contract_id\": \"f2bedc80-3dc8-4544-9140-9b606d71a6ee\", \"property_id\": \"usa/anytown/main-street/111\", \"contract_status\": \"APPROVED\" }" 7 | } 8 | ] -------------------------------------------------------------------------------- /unicorn_properties/tests/events/eventbridge/contract_status_changed_event_contract_1_draft.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "DetailType": "ContractStatusChanged", 4 | "Source": "unicorn.contracts", 5 | "EventBusName": "UnicornPropertiesBus-local", 6 | "Detail": "{ \"contract_last_modified_on\": \"10/08/2022 19:56:30\", \"contract_id\": \"f2bedc80-3dc8-4544-9140-9b606d71a6ee\", \"property_id\": \"usa/anytown/main-street/111\", \"contract_status\": \"DRAFT\" }" 7 | } 8 | ] 9 | -------------------------------------------------------------------------------- /unicorn_properties/tests/events/eventbridge/contract_status_changed_event_contract_2_approved.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "DetailType": "ContractStatusChanged", 4 | "Source": "unicorn.contracts", 5 | "EventBusName": "UnicornPropertiesBus-local", 6 | "Detail": "{ \"contract_last_modified_on\": \"10/08/2022 19:56:30\", \"contract_id\": \"9183453b-d284-4466-a2d9-f00b1d569ad7\", \"property_id\": \"usa/anytown/main-street/222\", \"contract_status\": \"APPROVED\" }" 7 | } 8 | ] 9 | -------------------------------------------------------------------------------- /unicorn_properties/tests/events/eventbridge/contract_status_changed_event_contract_2_draft.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "DetailType": "ContractStatusChanged", 4 | "Source": "unicorn.contracts", 5 | "EventBusName": "UnicornPropertiesBus-local", 6 | "Detail": "{ \"contract_last_modified_on\": \"10/08/2022 19:56:30\", \"contract_id\": \"9183453b-d284-4466-a2d9-f00b1d569ad7\", \"property_id\": \"usa/anytown/main-street/222\", \"contract_status\": \"DRAFT\" }" 7 | } 8 | ] 9 | -------------------------------------------------------------------------------- /unicorn_properties/tests/events/eventbridge/publication_approval_requested_event.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0", 3 | "id": "f849f683-76e1-1c84-669d-544a9828dfef", 4 | "detail-type": "PublicationApprovalRequested", 5 | "source": "unicorn.web", 6 | "account": "123456789012", 7 | "time": "2022-08-16T06:33:05Z", 8 | "region": "ap-southeast-2", 9 | "resources": [], 10 | "detail": { 11 | "property_id": "usa/anytown/main-street/111", 12 | "address": { 13 | "country": "USA", 14 | "city": "Anytown", 15 | "street": "Main Street", 16 | "number": 111 17 | }, 18 | "description": "This classic Anytown estate comes with a covetable lake view. The romantic and comfortable backyard is the perfect setting for unicorn get-togethers. The open concept Main Stable is fully equipped with all the desired amenities. Second floor features 6 straw bales including large Rainbow Suite with private training pool terrace and Jr Sparkles Suite.", 19 | "contract": "sale", 20 | "listprice": 200, 21 | "currency": "SPL", 22 | "images": [ 23 | "property_images/prop1_exterior1.jpg", 24 | "property_images/prop1_interior1.jpg", 25 | "property_images/prop1_interior2.jpg", 26 | "property_images/prop1_interior3.jpg", 27 | "property_images/prop1_interior4-bad.jpg" 28 | ] 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /unicorn_properties/tests/events/eventbridge/publication_approval_requested_event_all_good.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "DetailType": "PublicationApprovalRequested", 4 | "Source": "unicorn.web", 5 | "EventBusName": "UnicornPropertiesBus-local", 6 | "Detail": "{\"property_id\":\"usa/anytown/main-street/222\",\"address\":{\"country\":\"USA\",\"city\":\"Anytown\",\"street\":\"Main Street\",\"number\":222},\"description\":\"This classic Anytown estate comes with a covetable lake view. The romantic and comfortable backyard is the perfect setting for unicorn get-togethers. The open concept Main Stable is fully equipped with all the desired amenities. Second floor features 6 straw bales including large Rainbow Suite with private training pool terrace and Jr Sparkles Suite.\",\"contract\":\"sale\",\"listprice\":200,\"currency\":\"SPL\",\"images\":[\"property_images/prop1_exterior1.jpg\",\"property_images/prop1_interior1.jpg\",\"property_images/prop1_interior2.jpg\",\"property_images/prop1_interior3.jpg\"]}" 7 | } 8 | ] 9 | -------------------------------------------------------------------------------- /unicorn_properties/tests/events/eventbridge/publication_approval_requested_event_inappropriate_description.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "DetailType": "PublicationApprovalRequested", 4 | "Source": "unicorn.web", 5 | "EventBusName": "UnicornPropertiesBus-local", 6 | "Detail": "{\"property_id\":\"usa/anytown/main-street/111\",\"address\":{\"country\":\"USA\",\"city\":\"Anytown\",\"street\":\"Main Street\",\"number\":111},\"description\":\"This is a property for goblins. The property has the worst quality and is atrocious when it comes to design. The property is not clean whatsoever, and will make any property owner have buyers' remorse as soon the property is bought. Keep away from this property as much as possible!\",\"contract\":\"sale\",\"listprice\":200,\"currency\":\"SPL\",\"images\":[\"property_images/prop1_exterior1.jpg\",\"property_images/prop1_interior1.jpg\",\"property_images/prop1_interior2.jpg\",\"property_images/prop1_interior3.jpg\"]}" 7 | } 8 | ] 9 | -------------------------------------------------------------------------------- /unicorn_properties/tests/events/eventbridge/publication_approval_requested_event_inappropriate_images.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "DetailType": "PublicationApprovalRequested", 4 | "Source": "unicorn.web", 5 | "EventBusName": "UnicornPropertiesBus-local", 6 | "Detail": "{\"property_id\":\"usa/anytown/main-street/111\",\"address\":{\"country\":\"USA\",\"city\":\"Anytown\",\"street\":\"Main Street\",\"number\":111},\"description\":\"This classic Anytown estate comes with a covetable lake view. The romantic and comfortable backyard is the perfect setting for unicorn get-togethers. The open concept Main Stable is fully equipped with all the desired amenities. Second floor features 6 straw bales including large Rainbow Suite with private training pool terrace and Jr Sparkles Suite.\",\"contract\":\"sale\",\"listprice\":200,\"currency\":\"SPL\",\"images\":[\"property_images/prop1_exterior1.jpg\",\"property_images/prop1_interior1.jpg\",\"property_images/prop1_interior2.jpg\",\"property_images/prop1_interior3.jpg\",\"property_images/prop1_interior4-bad.jpg\"]}" 7 | } 8 | ] 9 | -------------------------------------------------------------------------------- /unicorn_properties/tests/events/eventbridge/publication_approval_requested_event_non_existing_contract.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "DetailType": "PublicationApprovalRequested", 4 | "Source": "unicorn.web", 5 | "EventBusName": "UnicornPropertiesBus-local", 6 | "Detail": "{\"property_id\":\"usa/anytown/main-street/333\",\"address\":{\"country\":\"USA\",\"city\":\"Anytown\",\"street\":\"Main Street\",\"number\":333},\"description\":\"This classic Anytown estate comes with a covetable lake view. The romantic and comfortable backyard is the perfect setting for unicorn get-togethers. The open concept Main Stable is fully equipped with all the desired amenities. Second floor features 6 straw bales including large Rainbow Suite with private training pool terrace and Jr Sparkles Suite.\",\"contract\":\"sale\",\"listprice\":200,\"currency\":\"SPL\",\"images\":[\"property_images/prop1_exterior1.jpg\",\"property_images/prop1_interior1.jpg\",\"property_images/prop1_interior2.jpg\",\"property_images/prop1_interior3.jpg\"]}" 7 | } 8 | ] 9 | -------------------------------------------------------------------------------- /unicorn_properties/tests/events/eventbridge/publication_approval_requested_event_pause_workflow.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "DetailType": "PublicationApprovalRequested", 4 | "Source": "unicorn.web", 5 | "EventBusName": "UnicornPropertiesBus-local", 6 | "Detail": "{\"property_id\":\"usa/anytown/main-street/111\",\"address\":{\"country\":\"USA\",\"city\":\"Anytown\",\"street\":\"Main Street\",\"number\":111},\"description\":\"This classic Anytown estate comes with a covetable lake view. The romantic and comfortable backyard is the perfect setting for unicorn get-togethers. The open concept Main Stable is fully equipped with all the desired amenities. Second floor features 6 straw bales including large Rainbow Suite with private training pool terrace and Jr Sparkles Suite.\",\"contract\":\"sale\",\"listprice\":200,\"currency\":\"SPL\",\"images\":[\"property_images/prop1_exterior1.jpg\",\"property_images/prop1_interior1.jpg\",\"property_images/prop1_interior2.jpg\",\"property_images/prop1_interior3.jpg\"]}" 7 | } 8 | ] 9 | -------------------------------------------------------------------------------- /unicorn_properties/tests/events/eventbridge/publication_evaluation_completed_event.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0", 3 | "id": "f849f683-76e1-1c84-669d-544a9828dfef", 4 | "detail-type": "PublicationEvaluationCompleted", 5 | "source": "unicorn.properties", 6 | "account": "123456789012", 7 | "time": "2022-08-16T06:33:05Z", 8 | "region": "ap-southeast-2", 9 | "resources": [], 10 | "detail": { 11 | "property_id": "usa/anytown/main-street/111", 12 | "evaluation_result": "APPROVED|DECLINED", 13 | "result_reason": "UNSAFE_IMAGE_DETECTED|BAD_SENTIMENT_DETECTED|..." 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /unicorn_properties/tests/events/eventbridge/put_event_property_approval_requested.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "Source": "unicorn.web", 4 | "Detail": "{ \"property_id\": \"usa/anytown/main-street/111\", \"country\": \"USA\", \"city\": \"Anytown\", \"street\": \"Main Street\", \"number\": 111, \"description\": \"This classic Anytown estate comes with a covetable lake view. The romantic and comfortable backyard is the perfect setting for unicorn get-togethers. The open concept Main Stable is fully equipped with all the desired amenities. Second floor features 6 straw bales including large Rainbow Suite with private training pool terrace and Jr Sparkles Suite.\", \"contract\": \"sale\", \"listprice\": 200, \"currency\": \"SPL\", \"images\": [ \"property_images/prop1_exterior1.jpg\", \"property_images/prop1_interior1.jpg\", \"property_images/prop1_interior2.jpg\", \"property_images/prop1_interior3.jpg\", \"property_images/prop1_interior4-bad.jpg\" ] }", 5 | "DetailType": "PublicationApprovalRequested", 6 | "EventBusName": "UnicornPropertiesBus-local" 7 | } 8 | ] 9 | -------------------------------------------------------------------------------- /unicorn_properties/tests/events/lambda/content_integrity_validator_function_success.json: -------------------------------------------------------------------------------- 1 | { 2 | "imageModerations": [ 3 | { 4 | "ModerationLabels": [] 5 | } 6 | ], 7 | "contentSentiment": { 8 | "Sentiment": "POSITIVE" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /unicorn_properties/tests/events/lambda/contract_status_changed.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0", 3 | "account": "123456789012", 4 | "region": "us-east-1", 5 | "detail-type": "ContractStatusChanged", 6 | "source": "unicorn.contracts", 7 | "time": "2022-08-14T22:06:31Z", 8 | "id": "c071bfbf-83c4-49ca-a6ff-3df053957145", 9 | "resources": [], 10 | "detail": { 11 | "contract_last_modified_on": "10/08/2022 19:56:30", 12 | "contract_id": "222", 13 | "property_id": "usa/anytown/main-street/123", 14 | "contract_status": "DRAFT" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /unicorn_properties/tests/events/lambda/contract_status_checker.json: -------------------------------------------------------------------------------- 1 | { 2 | "Input": { 3 | "property_id": "usa/anytown/main-street/123", 4 | "country": "USA", 5 | "city": "Anytown", 6 | "street": "Main Street", 7 | "number": 123, 8 | "description": "This classic Anytown estate comes with a covetable lake view. The romantic and comfortable backyard is the perfect setting for unicorn get-togethers. The open concept Main Stable is fully equipped with all the desired amenities. Second floor features 6 straw bales including large Rainbow Suite with private training pool terrace and Jr Sparkles Suite.", 9 | "contract": "sale", 10 | "listprice": 200.0, 11 | "currency": "SPL", 12 | "images": [ 13 | "usa/anytown/main-street-123-0d61b4e3" 14 | ] 15 | } 16 | } -------------------------------------------------------------------------------- /unicorn_properties/tests/events/lambda/wait_for_contract_approval_function.json: -------------------------------------------------------------------------------- 1 | { 2 | "TaskToken": "xxx", 3 | "Input": { 4 | "property_id": "usa/anytown/main-street/123" 5 | } 6 | } -------------------------------------------------------------------------------- /unicorn_properties/tests/integration/contract_status_changed_event.test.ts: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | import { DynamoDBClient, GetItemCommand } from '@aws-sdk/client-dynamodb'; 4 | import { 5 | sleep, 6 | findOutputValue, 7 | clearDatabase, 8 | initializeDatabase, 9 | } from './helper'; 10 | import { 11 | EventBridgeClient, 12 | PutEventsCommand, 13 | } from '@aws-sdk/client-eventbridge'; 14 | 15 | import ContractStatusChangedDraftEvent from '../events/eventbridge/contract_status_changed_event_contract_1_draft.json'; 16 | import ContractApprovedEvent from '../events/eventbridge/contract_status_changed_event_contract_2_approved.json'; 17 | 18 | describe('Testing draft contract event handling', () => { 19 | beforeAll(async () => { 20 | await clearDatabase(); 21 | await initializeDatabase(); 22 | }, 30000); 23 | 24 | afterAll(async () => { 25 | await clearDatabase(); 26 | }, 30000); 27 | 28 | it('Should create a draft contract', async () => { 29 | // Arrange 30 | const ddb = new DynamoDBClient({ 31 | region: process.env.AWS_DEFAULT_REGION, 32 | }); 33 | const evb = new EventBridgeClient({ 34 | region: process.env.AWS_DEFAULT_REGION, 35 | }); 36 | const contractStatusTableName = await findOutputValue( 37 | 'uni-prop-local-properties', 38 | 'ContractStatusTableName' 39 | ); 40 | 41 | // Act 42 | await evb.send( 43 | new PutEventsCommand({ Entries: ContractStatusChangedDraftEvent }) 44 | ); 45 | await sleep(10000); 46 | // Assert 47 | const getItemCommand = new GetItemCommand({ 48 | TableName: contractStatusTableName, 49 | Key: { property_id: { S: 'usa/anytown/main-street/111' } }, 50 | }); 51 | const ddbResp = await ddb.send(getItemCommand); 52 | expect(ddbResp?.Item).toBeTruthy(); 53 | if (!ddbResp?.Item) throw Error('Contract not found'); 54 | expect(ddbResp.Item.contract_status?.S).toBe('DRAFT'); 55 | expect(ddbResp.Item.sfn_wait_approved_task_token).toBe(undefined); 56 | }, 30000); 57 | 58 | it('Should update an existing contract status to APPROVED', async () => { 59 | // Arrange 60 | const ddb = new DynamoDBClient({ 61 | region: process.env.AWS_DEFAULT_REGION, 62 | }); 63 | const evb = new EventBridgeClient({ 64 | region: process.env.AWS_DEFAULT_REGION, 65 | }); 66 | const contractStatusTableName = await findOutputValue( 67 | 'uni-prop-local-properties', 68 | 'ContractStatusTableName' 69 | ); 70 | 71 | // Act 72 | await evb.send(new PutEventsCommand({ Entries: ContractApprovedEvent })); 73 | await sleep(5000); 74 | 75 | // Assert 76 | const getItemCommand = new GetItemCommand({ 77 | TableName: contractStatusTableName, 78 | Key: { property_id: { S: 'usa/anytown/main-street/222' } }, 79 | }); 80 | const ddbResp = await ddb.send(getItemCommand); 81 | expect(ddbResp?.Item).toBeTruthy(); 82 | if (!ddbResp.Item) throw new Error('Contract not found'); 83 | expect(ddbResp.Item.contract_status?.S).toBe('APPROVED'); 84 | }, 30000); 85 | }); 86 | -------------------------------------------------------------------------------- /unicorn_properties/tests/unit/contractStatusChangedEventHandler.test.ts: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | import { Context, EventBridgeEvent } from 'aws-lambda'; 4 | import { randomUUID } from 'crypto'; 5 | import { lambdaHandler } from '../../src/properties_service/contractStatusChangedEventHandler'; 6 | import { mockClient } from 'aws-sdk-client-mock'; 7 | import { 8 | DynamoDBClient, 9 | UpdateItemCommandInput, 10 | } from '@aws-sdk/client-dynamodb'; 11 | 12 | describe('Unit tests for contract creation', function () { 13 | const ddbMock = mockClient(DynamoDBClient); 14 | 15 | beforeEach(() => { 16 | ddbMock.reset(); 17 | }); 18 | 19 | test('verifies successful response', async () => { 20 | let cmd: any; 21 | 22 | const dateToCheck = new Date(); 23 | 24 | async function verifyInput(input: any) { 25 | cmd = input as UpdateItemCommandInput; 26 | expect(cmd['Key']['property_id'].S).toEqual('property1'); 27 | expect(cmd['ExpressionAttributeValues'][':c'].S).toEqual('contract1'); 28 | expect(cmd['ExpressionAttributeValues'][':t'].S).toEqual('APPROVED'); 29 | expect(cmd['ExpressionAttributeValues'][':m'].S).toEqual( 30 | dateToCheck.toISOString() 31 | ); 32 | return { 33 | $metadata: { 34 | httpStatusCode: 200, 35 | }, 36 | }; 37 | } 38 | 39 | ddbMock.callsFake(verifyInput); 40 | 41 | const expectedId = randomUUID(); 42 | const context: Context = { 43 | awsRequestId: expectedId, 44 | } as any; 45 | const event: EventBridgeEvent = { 46 | id: expectedId, 47 | account: 'nullAccount', 48 | version: '0', 49 | time: 'nulltime', 50 | region: 'ap-southeast-2', 51 | source: 'unicorn.properties', 52 | resources: [''], 53 | detail: { 54 | contract_id: 'contract1', 55 | property_id: 'property1', 56 | contract_status: 'APPROVED', 57 | contract_last_modified_on: dateToCheck.toISOString(), 58 | }, 59 | 'detail-type': 'ContractStatusChanged', 60 | }; 61 | 62 | await lambdaHandler(event, context); 63 | }); 64 | }); 65 | -------------------------------------------------------------------------------- /unicorn_properties/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2020", 4 | "strict": true, 5 | "types": ["node", "jest"], 6 | "baseUrl": "./", 7 | "paths": { 8 | "src/*": ["src/*"], 9 | "tests/*": ["tests/*"] 10 | }, 11 | "resolveJsonModule": true, 12 | "preserveConstEnums": true, 13 | "noEmit": true, 14 | "sourceMap": false, 15 | "module":"commonjs", 16 | "moduleResolution":"node", 17 | "esModuleInterop": true, 18 | "skipLibCheck": true, 19 | "forceConsistentCasingInFileNames": true, 20 | "experimentalDecorators": true 21 | }, 22 | "typeAcquisition": { "include": ["jest"] }, 23 | "exclude": ["node_modules"] 24 | } -------------------------------------------------------------------------------- /unicorn_shared/Makefile: -------------------------------------------------------------------------------- 1 | .ONESHELL: 2 | 3 | ENVIRONMENTS = local dev prod 4 | 5 | .PHONY: deploy-namespaces deploy-images delete-namespaces delete-images 6 | 7 | deploy-namespaces: ## Deploys global Unicorn Properties namespaces for all Stages 8 | aws cloudformation create-stack --stack-name uni-prop-namespaces --template-body file://uni-prop-namespaces.yaml --capabilities CAPABILITY_AUTO_EXPAND 9 | 10 | deploy-images: ## Deploys shared images stack for local dev prod stages 11 | @for env in $(ENVIRONMENTS); do \ 12 | stage=$$env; \ 13 | if ! aws cloudformation describe-stacks --stack-name "uni-prop-$$env-images" >/dev/null 2>&1; then \ 14 | echo "Creating shared images stack for $$env environment"; \ 15 | aws cloudformation create-stack \ 16 | --stack-name "uni-prop-$$env-images" \ 17 | --template-body file://uni-prop-images.yaml \ 18 | --parameters ParameterKey=Stage,ParameterValue=$$stage \ 19 | --capabilities CAPABILITY_IAM CAPABILITY_AUTO_EXPAND || echo "Stack creation failed!"; \ 20 | fi; \ 21 | done 22 | 23 | delete-namespaces: ## Depletes Unicorn Properties namespaces 24 | aws cloudformation delete-stack --stack-name uni-prop-namespaces 25 | 26 | delete-images: ## Deletes all shared images stacks 27 | @for env in $(ENVIRONMENTS); do \ 28 | stage=$$env; \ 29 | if aws cloudformation describe-stacks --stack-name "uni-prop-$$env-images" >/dev/null 2>&1; then \ 30 | echo "Deleting shared images stack for $$env environment"; \ 31 | aws cloudformation delete-stack \ 32 | --stack-name "uni-prop-$$env-images"; \ 33 | fi; \ 34 | done 35 | -------------------------------------------------------------------------------- /unicorn_shared/uni-prop-images.yaml: -------------------------------------------------------------------------------- 1 | AWSTemplateFormatVersion: "2010-09-09" 2 | Transform: AWS::Serverless-2016-10-31 3 | Description: > 4 | Base infrastructure that will set up the central event bus and S3 image upload bucket per stage. 5 | 6 | Metadata: 7 | cfn-lint: 8 | config: 9 | ignore_checks: 10 | - ES6000 11 | - WS1004 12 | 13 | Parameters: 14 | Stage: 15 | Type: String 16 | Default: local 17 | AllowedValues: 18 | - local 19 | - dev 20 | - prod 21 | 22 | Globals: 23 | Function: 24 | Timeout: 15 25 | Runtime: python3.12 26 | MemorySize: 512 27 | Tracing: Active 28 | Architectures: 29 | - arm64 30 | Tags: 31 | stage: !Ref Stage 32 | project: AWS Serverless Developer Experience 33 | service: Unicorn Base Infrastructure 34 | 35 | Resources: 36 | 37 | #### SSM PARAMETERS 38 | 39 | 40 | UnicornPropertiesImagesBucketParam: 41 | Type: AWS::SSM::Parameter 42 | Properties: 43 | Type: String 44 | Name: !Sub /uni-prop/${Stage}/ImagesBucket 45 | Value: !Ref UnicornPropertiesImagesBucket 46 | 47 | #### S3 PROPERTY IMAGES BUCKET 48 | UnicornPropertiesImagesBucket: 49 | Type: AWS::S3::Bucket 50 | Properties: 51 | BucketName: !Sub "uni-prop-${Stage}-images-${AWS::AccountId}-${AWS::Region}" 52 | 53 | #### IMAGE UPLOAD CUSTOM RESOURCE FUNCTION 54 | ImageUploadFunction: 55 | Type: AWS::Serverless::Function 56 | Properties: 57 | Handler: index.lambda_handler 58 | Runtime: python3.13 59 | Policies: 60 | - S3CrudPolicy: 61 | BucketName: !Ref UnicornPropertiesImagesBucket 62 | - Statement: 63 | - Sid: S3DeleteBucketPolicy 64 | Effect: Allow 65 | Action: 66 | - s3:DeleteBucket 67 | Resource: !GetAtt UnicornPropertiesImagesBucket.Arn 68 | InlineCode: | 69 | import os 70 | import zipfile 71 | from urllib.request import urlopen 72 | import boto3 73 | import cfnresponse 74 | 75 | zip_file_name = 'property_images.zip' 76 | url = f"https://aws-serverless-developer-experience-workshop-assets.s3.amazonaws.com/property_images/{zip_file_name}" 77 | temp_zip_download_location = f"/tmp/{zip_file_name}" 78 | 79 | s3 = boto3.resource('s3') 80 | 81 | def create(event, context): 82 | image_bucket_name = event['ResourceProperties']['DestinationBucket'] 83 | bucket = s3.Bucket(image_bucket_name) 84 | print(f"downloading zip file from: {url} to: {temp_zip_download_location}") 85 | r = urlopen(url).read() 86 | with open(temp_zip_download_location, 'wb') as t: 87 | t.write(r) 88 | print('zip file downloaded') 89 | 90 | print(f"unzipping file: {temp_zip_download_location}") 91 | with zipfile.ZipFile(temp_zip_download_location,'r') as zip_ref: 92 | zip_ref.extractall('/tmp') 93 | 94 | print('file unzipped') 95 | 96 | #### upload to s3 97 | for root,_,files in os.walk('/tmp/property_images'): 98 | for file in files: 99 | print(f"file: {os.path.join(root, file)}") 100 | print(f"s3 bucket: {image_bucket_name}") 101 | bucket.upload_file(os.path.join(root, file), f"property_images/{file}") 102 | def delete(event, context): 103 | image_bucket_name = event['ResourceProperties']['DestinationBucket'] 104 | img_bucket = s3.Bucket(image_bucket_name) 105 | img_bucket.objects.delete() 106 | img_bucket.delete() 107 | def lambda_handler(event, context): 108 | try: 109 | if event['RequestType'] in ['Create', 'Update']: 110 | create(event, context) 111 | elif event['RequestType'] in ['Delete']: 112 | delete(event, context) 113 | except Exception as e: 114 | print(e) 115 | cfnresponse.send(event, context, cfnresponse.SUCCESS, dict()) 116 | ImageUploadFunctionLogGroup: 117 | Type: AWS::Logs::LogGroup 118 | DeletionPolicy: Delete 119 | UpdateReplacePolicy: Delete 120 | Properties: 121 | LogGroupName: !Sub "/aws/lambda/${ImageUploadFunction}" 122 | 123 | ImageUpload: 124 | Type: Custom::ImageUpload 125 | Properties: 126 | ServiceToken: !GetAtt ImageUploadFunction.Arn 127 | DestinationBucket: !Ref UnicornPropertiesImagesBucket 128 | 129 | Outputs: 130 | ImageUploadBucketName: 131 | Value: !Ref UnicornPropertiesImagesBucket 132 | Description: "S3 bucket for property images" -------------------------------------------------------------------------------- /unicorn_shared/uni-prop-namespaces.yaml: -------------------------------------------------------------------------------- 1 | AWSTemplateFormatVersion: "2010-09-09" 2 | Transform: AWS::Serverless-2016-10-31 3 | Description: > 4 | Global namespaces for Unicorn Properties applications and services. 5 | This only needs to be deployed once. 6 | 7 | 8 | Resources: 9 | 10 | UnicornContractsNamespaceParam: 11 | Type: AWS::SSM::Parameter 12 | Properties: 13 | Type: String 14 | Name: !Sub /uni-prop/UnicornContractsNamespace 15 | Value: "unicorn.contracts" 16 | 17 | UnicornPropertiesNamespaceParam: 18 | Type: AWS::SSM::Parameter 19 | Properties: 20 | Type: String 21 | Name: !Sub /uni-prop/UnicornPropertiesNamespace 22 | Value: "unicorn.properties" 23 | 24 | UnicornWebNamespaceParam: 25 | Type: AWS::SSM::Parameter 26 | Properties: 27 | Type: String 28 | Name: !Sub /uni-prop/UnicornWebNamespace 29 | Value: "unicorn.web" 30 | 31 | 32 | Outputs: 33 | 34 | UnicornContractsNamespace: 35 | Description: Unicorn Contracts namespace parameter 36 | Value: !Ref UnicornContractsNamespaceParam 37 | 38 | UnicornPropertiesNamespace: 39 | Description: Unicorn Properties namespace parameter 40 | Value: !Ref UnicornPropertiesNamespaceParam 41 | 42 | UnicornWebNamespace: 43 | Description: Unicorn Web namespace parameter 44 | Value: !Ref UnicornWebNamespaceParam 45 | 46 | UnicornContractsNamespaceVale: 47 | Description: Unicorn Contracts namespace parameter value 48 | Value: !GetAtt UnicornContractsNamespaceParam.Value 49 | 50 | UnicornPropertiesNamespaceValue: 51 | Description: Unicorn Properties namespace parameter value 52 | Value: !GetAtt UnicornPropertiesNamespaceParam.Value 53 | 54 | UnicornWebNamespaceValue: 55 | Description: Unicorn Web namespace parameter value 56 | Value: !GetAtt UnicornWebNamespaceParam.Value -------------------------------------------------------------------------------- /unicorn_web/.gitignore: -------------------------------------------------------------------------------- 1 | ### CDK-specific ignores ### 2 | *.swp 3 | cdk.context.json 4 | package-lock.json 5 | yarn.lock 6 | .cdk.staging 7 | cdk.out 8 | 9 | # Created by https://www.toptal.com/developers/gitignore/api/node 10 | # Edit at https://www.toptal.com/developers/gitignore?templates=node 11 | 12 | ### Node ### 13 | # Logs 14 | logs 15 | *.log 16 | npm-debug.log* 17 | yarn-debug.log* 18 | yarn-error.log* 19 | lerna-debug.log* 20 | .pnpm-debug.log* 21 | 22 | # Diagnostic reports (https://nodejs.org/api/report.html) 23 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 24 | 25 | # Runtime data 26 | pids 27 | *.pid 28 | *.seed 29 | *.pid.lock 30 | 31 | # Directory for instrumented libs generated by jscoverage/JSCover 32 | lib-cov 33 | 34 | # Coverage directory used by tools like istanbul 35 | coverage 36 | *.lcov 37 | 38 | # nyc test coverage 39 | .nyc_output 40 | 41 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 42 | .grunt 43 | 44 | # Bower dependency directory (https://bower.io/) 45 | bower_components 46 | 47 | # node-waf configuration 48 | .lock-wscript 49 | 50 | # Compiled binary addons (https://nodejs.org/api/addons.html) 51 | build/Release 52 | 53 | # Dependency directories 54 | node_modules/ 55 | jspm_packages/ 56 | 57 | # Snowpack dependency directory (https://snowpack.dev/) 58 | web_modules/ 59 | 60 | # TypeScript cache 61 | *.tsbuildinfo 62 | 63 | # Optional npm cache directory 64 | .npm 65 | 66 | # Optional eslint cache 67 | .eslintcache 68 | 69 | # Optional stylelint cache 70 | .stylelintcache 71 | 72 | # Microbundle cache 73 | .rpt2_cache/ 74 | .rts2_cache_cjs/ 75 | .rts2_cache_es/ 76 | .rts2_cache_umd/ 77 | 78 | # Optional REPL history 79 | .node_repl_history 80 | 81 | # Output of 'npm pack' 82 | *.tgz 83 | 84 | # Yarn Integrity file 85 | .yarn-integrity 86 | 87 | # dotenv environment variable files 88 | .env 89 | .env.development.local 90 | .env.test.local 91 | .env.production.local 92 | .env.local 93 | 94 | # parcel-bundler cache (https://parceljs.org/) 95 | .cache 96 | .parcel-cache 97 | 98 | # Next.js build output 99 | .next 100 | out 101 | 102 | # Nuxt.js build / generate output 103 | .nuxt 104 | dist 105 | 106 | # Gatsby files 107 | .cache/ 108 | # Comment in the public line in if your project uses Gatsby and not Next.js 109 | # https://nextjs.org/blog/next-9-1#public-directory-support 110 | # public 111 | 112 | # vuepress build output 113 | .vuepress/dist 114 | 115 | # vuepress v2.x temp and cache directory 116 | .temp 117 | 118 | # Docusaurus cache and generated files 119 | .docusaurus 120 | 121 | # Serverless directories 122 | .serverless/ 123 | 124 | # FuseBox cache 125 | .fusebox/ 126 | 127 | # DynamoDB Local files 128 | .dynamodb/ 129 | 130 | # TernJS port file 131 | .tern-port 132 | 133 | # Stores VSCode versions used for testing VSCode extensions 134 | .vscode-test 135 | 136 | # yarn v2 137 | .yarn/cache 138 | .yarn/unplugged 139 | .yarn/build-state.yml 140 | .yarn/install-state.gz 141 | .pnp.* 142 | 143 | ### Node Patch ### 144 | # Serverless Webpack directories 145 | .webpack/ 146 | 147 | # Optional stylelint cache 148 | 149 | # SvelteKit build / generate output 150 | .svelte-kit 151 | 152 | # End of https://www.toptal.com/developers/gitignore/api/node 153 | 154 | ### Linux ### 155 | **/*~ 156 | **/.fuse_hidden* 157 | **/.directory 158 | **/.Trash-* 159 | **/.nfs* 160 | 161 | ### OSX ### 162 | **/*.DS_Store 163 | **/.AppleDouble 164 | **/.LSOverride 165 | **/.DocumentRevisions-V100 166 | **/.fseventsd 167 | **/.Spotlight-V100 168 | **/.TemporaryItems 169 | **/.Trashes 170 | **/.VolumeIcon.icns 171 | **/.com.apple.timemachine.donotpresent 172 | 173 | ### JetBrains IDEs ### 174 | **/*.iws 175 | **/.idea/ 176 | **/.idea_modules/ 177 | 178 | ### Python ### 179 | **/__pycache__/ 180 | **/*.py[cod] 181 | **/*$py.class 182 | **/.Python 183 | **/build/ 184 | **/develop-eggs/ 185 | **/dist/ 186 | **/downloads/ 187 | **/eggs/ 188 | **/.eggs/ 189 | **/parts/ 190 | **/sdist/ 191 | **/wheels/ 192 | **/*.egg 193 | **/*.egg-info/ 194 | **/.installed.cfg 195 | **/pip-log.txt 196 | **/pip-delete-this-directory.txt 197 | 198 | ### Unit test / coverage reports ### 199 | **/.cache 200 | **/.coverage 201 | **/.hypothesis/ 202 | **/.pytest_cache/ 203 | **/.tox/ 204 | **/*.cover 205 | **/coverage.xml 206 | **/htmlcov/ 207 | **/nosetests.xml 208 | 209 | ### pyenv ### 210 | **/.python-version 211 | 212 | ### Environments ### 213 | **/.env 214 | **/.venv 215 | **/env/ 216 | **/venv/ 217 | **/ENV/ 218 | **/env.bak/ 219 | **/venv.bak/ 220 | 221 | ### VisualStudioCode ### 222 | **/.vscode/ 223 | **/.history 224 | 225 | ### Windows ### 226 | # Windows thumbnail cache files 227 | **/Thumbs.db 228 | **/ehthumbs.db 229 | **/ehthumbs_vista.db 230 | **/Desktop.ini 231 | **/$RECYCLE.BIN/ 232 | **/*.lnk 233 | 234 | ### requirements.txt ### 235 | # We ignore Python's requirements.txt as we use Poetry instead 236 | **/requirements.txt 237 | **/.aws-sam -------------------------------------------------------------------------------- /unicorn_web/.npmignore: -------------------------------------------------------------------------------- 1 | tests/* 2 | -------------------------------------------------------------------------------- /unicorn_web/.prettierignore: -------------------------------------------------------------------------------- 1 | **/template.yaml -------------------------------------------------------------------------------- /unicorn_web/.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | trailingComma: "es5", 3 | tabWidth: 2, 4 | semi: true, 5 | singleQuote: true, 6 | }; 7 | 8 | -------------------------------------------------------------------------------- /unicorn_web/Makefile: -------------------------------------------------------------------------------- 1 | #### Global Variables 2 | stackName := $(shell yq -oy '.default.global.parameters.stack_name' samconfig.yaml) 3 | 4 | 5 | #### Build/Deploy Tasks 6 | ci: deps clean build deploy 7 | deps: 8 | pnpm i 9 | 10 | build: 11 | sam build -c $(DOCKER_OPTS) 12 | 13 | deploy: deps build 14 | sam deploy --no-confirm-changeset 15 | 16 | 17 | 18 | #### Test Variables 19 | apiUrl = $(call cf_output,$(stackName),ApiUrl) 20 | 21 | 22 | #### Tests 23 | test: unit-test integration-test 24 | 25 | unit-test: 26 | pnpm test -- tests/unit/ --passWithNoTests 27 | 28 | integration-test: 29 | pnpm test -- tests/integration/ 30 | 31 | curl-test: 32 | $(call mcurl,GET,search/usa/anytown) 33 | $(call mcurl,GET,search/usa/anytown/main-street) 34 | $(call mcurl,GET,properties/usa/anytown/main-street/111) 35 | @echo "[DONE]" 36 | 37 | 38 | #### Utilities 39 | sync: 40 | sam sync --stack-name $(stackName) --watch 41 | 42 | logs: 43 | sam logs --stack-name $(stackName) -t 44 | 45 | clean: 46 | find . -type d -name __pycache__ -exec rm -rf {} \; 2>/dev/null || true 47 | find . -type f -name requirements.txt -exec rm -f {} \; 2>/dev/null || true 48 | pnpm exec jest --clearCache 49 | rm -rf .node_modules/ .aws-sam/ htmlcov/ .coverage || true 50 | 51 | delete: 52 | sam delete --no-prompts --region "$$(aws configure get region)" 53 | 54 | ci_init: 55 | npm ci 56 | 57 | 58 | #### Helper Functions 59 | define mcurl 60 | curl -s -X $(1) -H "Content-type: application/json" $(apiUrl)$(2) | jq 61 | endef 62 | 63 | define cf_output 64 | $(shell aws cloudformation describe-stacks \ 65 | --output text \ 66 | --stack-name $(1) \ 67 | --query 'Stacks[0].Outputs[?OutputKey==`$(2)`].OutputValue') 68 | endef 69 | -------------------------------------------------------------------------------- /unicorn_web/README.md: -------------------------------------------------------------------------------- 1 | # Developing Unicorn Web 2 | 3 | ![Properties Web Architecture](https://static.us-east-1.prod.workshops.aws/public/f273b5fc-17cd-406b-9e63-1d331b00589d/static/images/architecture-properties-web.png) 4 | 5 | ## Architecture Overview 6 | 7 | Unicorn Web is primarily responsible for allowing customers to search and view property listings. It also supports ability for agents to request approval for specific property. Those approval requests are sent to Property service for validation, before Properties table is updated with approval evaluation results. 8 | 9 | A core component of Unicorn Web are the Lambda functions which are responsible with completing API Gateway requests to: 10 | 11 | - search approved property listings 12 | This function interacts with DynamoDB table to retrieve property listings marked as `APPROVED`. The API Gateway implementation and lambda code support multiple types of search patterns, and allow searching by city, street, or house number. 13 | 14 | - request approval of property listing 15 | This function sends an event to EventBridge requesting an approval for a property listing specified in the payload sent from client 16 | 17 | - publication approved function 18 | There is also a lambda function responsible for receiving any "Approval Evaluation Completed" events from EventBridge. This function writes the evaluation result to DynamoDB table. 19 | 20 | ### Testing the APIs 21 | 22 | ```bash 23 | export API=`aws cloudformation describe-stacks --stack-name uni-prop-local-web --query "Stacks[0].Outputs[?OutputKey=='ApiUrl'].OutputValue" --output text` 24 | 25 | curl --location --request POST "${API}request_approval" \ 26 | --header 'Content-Type: application/json' \ 27 | --data-raw '{"property_id": "usa/anytown/main-street/111"}' 28 | 29 | 30 | curl -X POST ${API_URL}request_approval \ 31 | -H 'Content-Type: application/json' \ 32 | -d '{"property_id":"usa/anytown/main-street/111"}' | jq 33 | ``` 34 | -------------------------------------------------------------------------------- /unicorn_web/data/approved_property.json: -------------------------------------------------------------------------------- 1 | { 2 | "PK": "PROPERTY#au#anytown", 3 | "SK": "main-street#1337", 4 | "country": "AU", 5 | "city": "Anytown", 6 | "street": "Main Street", 7 | "number": 1337, 8 | "description": "This classic Anytown estate comes with a covetable lake view. The romantic and comfortable backyard is the perfect setting for unicorn get-togethers. The open concept Main Stable is fully equipped with all the desired amenities. Second floor features 6 straw bales including large Rainbow Suite with private training pool terrace and Jr Sparkles Suite.", 9 | "contract": "sale", 10 | "listprice": 200, 11 | "currency": "SPL", 12 | "images": [ 13 | "property_images/prop1_exterior1.jpg", 14 | "property_images/prop1_interior1.jpg", 15 | "property_images/prop1_interior2.jpg", 16 | "property_images/prop1_interior3.jpg" 17 | ], 18 | "status": "APPROVED" 19 | } -------------------------------------------------------------------------------- /unicorn_web/data/load_data.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | # SPDX-License-Identifier: MIT-0 4 | ROOT_DIR="$(cd -- "$(dirname "$0")/../" >/dev/null 2>&1 ; pwd -P )" 5 | STACK_NAME="$(yq -ot '.default.global.parameters.stack_name' $ROOT_DIR/samconfig.toml)" 6 | 7 | JSON_FILE="$(cd -- "$(dirname "$0")" >/dev/null 2>&1 ; pwd -P )/property_data.json" 8 | echo "JSON_FILE: '${JSON_FILE}'" 9 | 10 | DDB_TBL_NAME="$(aws cloudformation describe-stacks --stack-name ${STACK_NAME} --query 'Stacks[0].Outputs[?ends_with(OutputKey, `WebTableName`)].OutputValue' --output text)" 11 | echo "DDB_TABLE_NAME: '${DDB_TBL_NAME}'" 12 | 13 | echo "LOADING ITEMS TO DYNAMODB:" 14 | aws ddb put ${DDB_TBL_NAME} file://${JSON_FILE} 15 | echo "DONE!" 16 | -------------------------------------------------------------------------------- /unicorn_web/data/property_data.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "PK": "PROPERTY#usa#anytown", 4 | "SK": "main-street#111", 5 | "country": "USA", 6 | "city": "Anytown", 7 | "street": "Main Street", 8 | "number": 111, 9 | "description": "This classic Anytown estate comes with a covetable lake view. The romantic and comfortable backyard is the perfect setting for unicorn get-togethers. The open concept Main Stable is fully equipped with all the desired amenities. Second floor features 6 straw bales including large Rainbow Suite with private training pool terrace and Jr Sparkles Suite.", 10 | "contract": "sale", 11 | "listprice": 200, 12 | "currency": "SPL", 13 | "images": [ 14 | "property_images/prop1_exterior1.jpg", 15 | "property_images/prop1_interior1.jpg", 16 | "property_images/prop1_interior2.jpg", 17 | "property_images/prop1_interior3.jpg" 18 | ], 19 | "status": "PENDING" 20 | }, 21 | { 22 | "PK": "PROPERTY#usa#main-town", 23 | "SK": "my-street#222", 24 | "country": "USA", 25 | "city": "Main Town", 26 | "street": "My Street", 27 | "number": 222, 28 | "description": "This picturesque Main Town property is ideally suited for the DIY enthusiast unicorns. The antique art-nouveau pillars at the entrance offer a reminder of historic times. This property comes uniquely suited with no electricity, offering a tranquil environment with no digital disruptions. The rooftop has only minor fire damages from fireworks incidents during the 1970s.", 29 | "contract": "sale", 30 | "listprice": 150, 31 | "currency": "SPL", 32 | "images": [ 33 | "property_images/prop2_exterior1.jpg", 34 | "property_images/prop2_exterior2.jpg", 35 | "property_images/prop2_interior1.jpg", 36 | "property_images/prop2_interior2.jpg" 37 | ], 38 | "status": "PENDING" 39 | }, 40 | { 41 | "PK": "PROPERTY#usa#anytown", 42 | "SK": "main-street#333", 43 | "country": "USA", 44 | "city": "Anytown", 45 | "street": "Main Street", 46 | "number": 333, 47 | "description": "State of the art mansion featuring the most luxurious facilities, not only of Anytown but the whole Anytown region. The garden is equipped with three separate landing pads; one for the owner, one for guests and one for employees of the owner. The estate has its own satellite dish receiver and is equipped with double fiber connections for high-speed internet. The house has both an indoor and an outdoor swimming pool with automated pool cleaning facilities.", 48 | "contract": "sale", 49 | "listprice": 250, 50 | "currency": "SPL", 51 | "images": [ 52 | "property_images/prop3_exterior1.jpg", 53 | "property_images/prop3_interior1.jpg", 54 | "property_images/prop3_interior2.jpg", 55 | "property_images/prop3_interior3.jpg" 56 | ], 57 | "status": "PENDING" 58 | } 59 | ] -------------------------------------------------------------------------------- /unicorn_web/eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import eslint from "@eslint/js"; 2 | import tseslint from "typescript-eslint"; 3 | import prettierConfig from 'eslint-config-prettier' 4 | 5 | export default tseslint.config( 6 | { 7 | ignores: ["**/node_modules", "**/.aws-sam", "**/template.yaml", "**/cdk.out", "**/.github", "**/jest.config.js", "**/.prettierrc.js"], 8 | }, 9 | eslint.configs.recommended, 10 | tseslint.configs.stylistic, 11 | prettierConfig, 12 | { 13 | rules: { 14 | "no-unused-vars": "off", 15 | "@typescript-eslint/no-unused-vars": ["error"] 16 | } 17 | } 18 | ); -------------------------------------------------------------------------------- /unicorn_web/integration/PublicationApprovalRequested.json: -------------------------------------------------------------------------------- 1 | { 2 | "openapi": "3.0.0", 3 | "info": { 4 | "version": "1.0.0", 5 | "title": "PublicationApprovalRequested" 6 | }, 7 | "paths": {}, 8 | "components": { 9 | "schemas": { 10 | "AWSEvent": { 11 | "type": "object", 12 | "required": [ 13 | "detail-type", 14 | "resources", 15 | "detail", 16 | "id", 17 | "source", 18 | "time", 19 | "region", 20 | "version", 21 | "account" 22 | ], 23 | "x-amazon-events-detail-type": "PublicationApprovalRequested", 24 | "x-amazon-events-source": "unicorn.web", 25 | "properties": { 26 | "detail": { 27 | "$ref": "#/components/schemas/PublicationApprovalRequested" 28 | }, 29 | "account": { 30 | "type": "string" 31 | }, 32 | "detail-type": { 33 | "type": "string" 34 | }, 35 | "id": { 36 | "type": "string" 37 | }, 38 | "region": { 39 | "type": "string" 40 | }, 41 | "resources": { 42 | "type": "array", 43 | "items": { 44 | "type": "string" 45 | } 46 | }, 47 | "source": { 48 | "type": "string" 49 | }, 50 | "time": { 51 | "type": "string", 52 | "format": "date-time" 53 | }, 54 | "version": { 55 | "type": "string" 56 | } 57 | } 58 | }, 59 | "PublicationApprovalRequested": { 60 | "type": "object", 61 | "required": [ 62 | "images", 63 | "address", 64 | "listprice", 65 | "contract", 66 | "description", 67 | "currency", 68 | "property_id", 69 | "status" 70 | ], 71 | "properties": { 72 | "address": { 73 | "$ref": "#/components/schemas/Address" 74 | }, 75 | "contract": { 76 | "type": "string" 77 | }, 78 | "currency": { 79 | "type": "string" 80 | }, 81 | "description": { 82 | "type": "string" 83 | }, 84 | "images": { 85 | "type": "array", 86 | "items": { 87 | "type": "string" 88 | } 89 | }, 90 | "listprice": { 91 | "type": "string" 92 | }, 93 | "property_id": { 94 | "type": "string" 95 | }, 96 | "status": { 97 | "type": "string" 98 | } 99 | } 100 | }, 101 | "Address": { 102 | "type": "object", 103 | "required": [ 104 | "country", 105 | "number", 106 | "city", 107 | "street" 108 | ], 109 | "properties": { 110 | "city": { 111 | "type": "string" 112 | }, 113 | "country": { 114 | "type": "string" 115 | }, 116 | "number": { 117 | "type": "string" 118 | }, 119 | "street": { 120 | "type": "string" 121 | } 122 | } 123 | } 124 | } 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /unicorn_web/integration/subscriber-policies.yaml: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: MIT-0 3 | AWSTemplateFormatVersion: "2010-09-09" 4 | Description: > 5 | Defines the event bus policies that determine who can create rules on the event bus to 6 | subscribe to events published by Unicorn Web Service. 7 | 8 | Parameters: 9 | Stage: 10 | Type: String 11 | Default: local 12 | AllowedValues: 13 | - local 14 | - dev 15 | - prod 16 | 17 | Resources: 18 | # Update this policy as you get new subscribers by adding their namespace to events:source 19 | CrossServiceCreateRulePolicy: 20 | Type: AWS::Events::EventBusPolicy 21 | Properties: 22 | EventBusName: 23 | Fn::Sub: "{{resolve:ssm:/uni-prop/${Stage}/UnicornWebEventBus}}" 24 | StatementId: 25 | Fn::Sub: "OnlyRulesForWebServiceEvents-${Stage}" 26 | Statement: 27 | Effect: Allow 28 | Principal: 29 | AWS: 30 | Fn::Sub: "arn:${AWS::Partition}:iam::${AWS::AccountId}:root" 31 | Action: 32 | - events:PutRule 33 | - events:DeleteRule 34 | - events:DescribeRule 35 | - events:DisableRule 36 | - events:EnableRule 37 | - events:PutTargets 38 | - events:RemoveTargets 39 | Resource: 40 | - Fn::Sub: 41 | - arn:${AWS::Partition}:events:${AWS::Region}:${AWS::AccountId}:rule/${eventBusName}/* 42 | - eventBusName: 43 | Fn::Sub: "{{resolve:ssm:/uni-prop/${Stage}/UnicornWebEventBus}}" 44 | Condition: 45 | StringEqualsIfExists: 46 | "events:creatorAccount": "${aws:PrincipalAccount}" 47 | StringEquals: 48 | "events:source": 49 | - "{{resolve:ssm:/uni-prop/UnicornWebNamespace}}" 50 | "Null": 51 | "events:source": "false" 52 | -------------------------------------------------------------------------------- /unicorn_web/integration/subscriptions.yaml: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: MIT-0 3 | AWSTemplateFormatVersion: "2010-09-09" 4 | Description: Defines the rule for the events (subscriptions) that Unicorn Properties wants to consume. 5 | 6 | Parameters: 7 | Stage: 8 | Type: String 9 | Default: local 10 | AllowedValues: 11 | - local 12 | - dev 13 | - prod 14 | 15 | Resources: 16 | #### UNICORN PROPERTIES EVENT SUBSCRIPTIONS 17 | PublicationEvaluationCompletedSubscriptionRule: 18 | Type: AWS::Events::Rule 19 | Properties: 20 | Name: unicorn.web-PublicationEvaluationCompleted 21 | Description: PublicationEvaluationCompleted subscription 22 | EventBusName: 23 | Fn::Sub: "{{resolve:ssm:/uni-prop/${Stage}/UnicornPropertiesEventBusArn}}" 24 | EventPattern: 25 | source: 26 | - "{{resolve:ssm:/uni-prop/UnicornPropertiesNamespace}}" 27 | detail-type: 28 | - PublicationEvaluationCompleted 29 | State: ENABLED 30 | Targets: 31 | - Id: SendEventTo 32 | Arn: 33 | Fn::Sub: "{{resolve:ssm:/uni-prop/${Stage}/UnicornWebEventBusArn}}" 34 | RoleArn: 35 | Fn::GetAtt: [ UnicornPropertiesEventBusToUnicornWebEventBusRole, Arn ] 36 | 37 | # This IAM role allows EventBridge to assume the permissions necessary to send events 38 | # from the publishing event bus, to the subscribing event bus (UnicornWebEventBusArn) 39 | UnicornPropertiesEventBusToUnicornWebEventBusRole: 40 | Type: AWS::IAM::Role 41 | Properties: 42 | AssumeRolePolicyDocument: 43 | Statement: 44 | - Effect: Allow 45 | Action: sts:AssumeRole 46 | Principal: 47 | Service: events.amazonaws.com 48 | Policies: 49 | - PolicyName: PutEventsOnUnicornWebEventBus 50 | PolicyDocument: 51 | Version: "2012-10-17" 52 | Statement: 53 | - Effect: Allow 54 | Action: events:PutEvents 55 | Resource: 56 | Fn::Sub: "{{resolve:ssm:/uni-prop/${Stage}/UnicornWebEventBusArn}}" 57 | 58 | Outputs: 59 | PublicationEvaluationCompletedSubscription: 60 | Description: Rule ARN for Property service event subscription 61 | Value: 62 | Fn::GetAtt: [ PublicationEvaluationCompletedSubscriptionRule, Arn ] 63 | -------------------------------------------------------------------------------- /unicorn_web/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: "ts-jest", 3 | transform: { 4 | "^.+\\.ts?$": "ts-jest", 5 | }, 6 | moduleFileExtensions: ["js", "ts"], 7 | collectCoverageFrom: ["**/src/**/*.ts", "!**/node_modules/**"], 8 | testMatch: ["**/tests/unit/*.test.ts", "**/tests/integration/*.test.ts"], 9 | testPathIgnorePatterns: ["/node_modules/"], 10 | testEnvironment: "node", 11 | coverageProvider: "v8", 12 | }; 13 | -------------------------------------------------------------------------------- /unicorn_web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "contracts", 3 | "version": "1.0.0", 4 | "description": "Contracts Module for Serverless Developer Experience Reference Architecture - Node", 5 | "repository": "https://github.com/aws-samples/aws-serverless-developer-experience-workshop-typescript/tree/develop", 6 | "license": "MIT", 7 | "author": "Amazon.com, Inc.", 8 | "main": "app.js", 9 | "scripts": { 10 | "build": "sam build", 11 | "compile": "tsc", 12 | "deploy": "sam build && sam deploy --no-confirm-changeset", 13 | "lint": "eslint --ext .ts --quiet --fix", 14 | "test": "jest --runInBand", 15 | "unit": "jest --config=jest.config.test.unit.ts" 16 | }, 17 | "dependencies": { 18 | "@aws-lambda-powertools/commons": "^2.20.0", 19 | "@aws-lambda-powertools/logger": "^2.20.0", 20 | "@aws-lambda-powertools/metrics": "^2.20.0", 21 | "@aws-lambda-powertools/tracer": "^2.20.0", 22 | "@aws-sdk/client-cloudformation": "^3.812.0", 23 | "@aws-sdk/client-cloudwatch-logs": "^3.812.0", 24 | "@aws-sdk/client-dynamodb": "^3.812.0", 25 | "@aws-sdk/client-eventbridge": "^3.812.0", 26 | "@aws-sdk/lib-dynamodb": "^3.814.0", 27 | "@aws-sdk/util-dynamodb": "^3.812.0", 28 | "aws-lambda": "^1.0.7" 29 | }, 30 | "devDependencies": { 31 | "@types/aws-lambda": "^8.10.149", 32 | "@types/cfn-response": "^1.0.8", 33 | "@types/jest": "^29.5.14", 34 | "@types/node": "^22.15.21", 35 | "aws-sdk-client-mock": "^4.1.0", 36 | "esbuild": "^0.25.4", 37 | "esbuild-jest": "^0.5.0", 38 | "eslint": "^9.27.0", 39 | "eslint-config-prettier": "^10.1.5", 40 | "globals": "^16.1.0", 41 | "jest": "^29.7.0", 42 | "prettier": "^3.5.3", 43 | "ts-jest": "^29.3.4", 44 | "ts-node": "^10.9.2", 45 | "typescript": "^5.8.3", 46 | "typescript-eslint": "^8.32.1" 47 | } 48 | } -------------------------------------------------------------------------------- /unicorn_web/samconfig.toml: -------------------------------------------------------------------------------- 1 | version = 0.1 2 | 3 | [default.global.parameters] 4 | stack_name = "uni-prop-local-web" 5 | s3_prefix = "uni-prop-local-web" 6 | resolve_s3 = true 7 | resolve_image_repositories = true 8 | 9 | [default.build.parameters] 10 | cached = true 11 | parallel = true 12 | 13 | [default.deploy.parameters] 14 | disable_rollback = true 15 | confirm_changeset = false 16 | fail_on_empty_changeset = false 17 | capabilities = ["CAPABILITY_IAM", "CAPABILITY_AUTO_EXPAND"] 18 | parameter_overrides = ["Stage=local"] 19 | 20 | [default.validate.parameters] 21 | lint = true 22 | 23 | [default.sync.parameters] 24 | watch = true 25 | 26 | [default.local_start_api.parameters] 27 | warm_containers = "EAGER" 28 | 29 | [default.local_start_lambda.parameters] 30 | warm_containers = "EAGER" -------------------------------------------------------------------------------- /unicorn_web/src/approvals_service/powertools.ts: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | import { Metrics } from '@aws-lambda-powertools/metrics'; 4 | import { Logger } from '@aws-lambda-powertools/logger'; 5 | import type { LogLevel } from '@aws-lambda-powertools/logger/types'; 6 | import { Tracer } from '@aws-lambda-powertools/tracer'; 7 | 8 | const SERVICE_NAMESPACE = process.env.SERVICE_NAMESPACE ?? 'test_namespace'; 9 | 10 | const defaultValues = { 11 | region: process.env.AWS_REGION || 'N/A', 12 | executionEnv: process.env.AWS_EXECUTION_ENV || 'N/A', 13 | }; 14 | 15 | const logger = new Logger({ 16 | logLevel: (process.env.LOG_LEVEL || 'INFO') as LogLevel, 17 | persistentLogAttributes: { 18 | ...defaultValues, 19 | logger: { 20 | name: '@aws-lambda-powertools/logger', 21 | }, 22 | }, 23 | }); 24 | 25 | const metrics = new Metrics({ 26 | defaultDimensions: defaultValues, 27 | namespace: SERVICE_NAMESPACE, 28 | }); 29 | 30 | const tracer = new Tracer(); 31 | 32 | export { metrics, logger, tracer }; 33 | -------------------------------------------------------------------------------- /unicorn_web/src/approvals_service/publicationApprovedEventHandler.ts: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | import { EventBridgeEvent, Context } from 'aws-lambda'; 4 | import type { LambdaInterface } from '@aws-lambda-powertools/commons/types'; 5 | import { MetricUnit } from '@aws-lambda-powertools/metrics'; 6 | import { logger, metrics, tracer } from './powertools'; 7 | import { 8 | DynamoDBClient, 9 | UpdateItemCommand, 10 | UpdateItemCommandInput, 11 | } from '@aws-sdk/client-dynamodb'; 12 | import { PublicationEvaluationCompleted } from '../schema/unicorn_properties/publicationevaluationcompleted/PublicationEvaluationCompleted'; 13 | import { Marshaller } from '../schema/unicorn_properties/publicationevaluationcompleted/marshaller/Marshaller'; 14 | 15 | // Empty configuration for DynamoDB 16 | const ddbClient = new DynamoDBClient({}); 17 | const DDB_TABLE = process.env.DYNAMODB_TABLE; 18 | 19 | class PublicationApprovedFunction implements LambdaInterface { 20 | /** 21 | * Handle the contract status changed event from the EventBridge instance. 22 | * @param {EventBridgeEvent} event - EventBridge Event Input Format 23 | * @returns {void} 24 | * 25 | */ 26 | @tracer.captureLambdaHandler() 27 | @metrics.logMetrics({ captureColdStartMetric: true }) 28 | @logger.injectLambdaContext({ logEvent: true }) 29 | public async handler( 30 | event: EventBridgeEvent, 31 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 32 | context: Context 33 | ): Promise { 34 | logger.info(`Property status changed: ${JSON.stringify(event.detail)}`); 35 | // Construct the entry to insert into database. 36 | const propertyEvaluation: PublicationEvaluationCompleted = 37 | Marshaller.unmarshal(event.detail, 'PublicationEvaluationCompleted'); 38 | logger.info(`Unmarshalled entry: ${JSON.stringify(propertyEvaluation)}`); 39 | 40 | try { 41 | await this.publicationApproved(propertyEvaluation); 42 | } catch (error: any) { 43 | tracer.addErrorAsMetadata(error as Error); 44 | logger.error(`Error during DDB UPDATE: ${JSON.stringify(error)}`); 45 | } 46 | metrics.addMetric('ContractUpdated', MetricUnit.Count, 1); 47 | } 48 | 49 | /** 50 | * Update the Property entry in the database 51 | * @private 52 | * @async 53 | * @method publicationApproved 54 | * @param {PublicationEvaluationCompleted} event - The EventBridge event when a contract changes 55 | * @returns {Promise} - A promise that resolves when all records have been processed. 56 | */ 57 | @tracer.captureMethod() 58 | private async publicationApproved( 59 | propertyEvaluation: PublicationEvaluationCompleted 60 | ) { 61 | tracer.putAnnotation('propertyId', propertyEvaluation.propertyId); 62 | logger.info( 63 | `Updating status: ${propertyEvaluation.evaluationResult} for ${propertyEvaluation.propertyId}` 64 | ); 65 | const propertyId = propertyEvaluation.propertyId; 66 | const { PK, SK } = this.getDynamoDBKeys(propertyId); 67 | const updateItemCommandInput: UpdateItemCommandInput = { 68 | Key: { PK: { S: PK }, SK: { S: SK } }, 69 | ExpressionAttributeNames: { 70 | '#s': 'status', 71 | }, 72 | ExpressionAttributeValues: { 73 | ':t': { 74 | S: propertyEvaluation.evaluationResult, 75 | }, 76 | }, 77 | UpdateExpression: 'SET #s = :t', 78 | TableName: DDB_TABLE, 79 | }; 80 | 81 | const data = await ddbClient.send( 82 | new UpdateItemCommand(updateItemCommandInput) 83 | ); 84 | if (data.$metadata.httpStatusCode !== 200) { 85 | throw new Error( 86 | `Unable to update status for property PK ${PK} and SK ${SK}` 87 | ); 88 | } 89 | logger.info(`Updated status for property PK ${PK} and SK ${SK}`); 90 | } 91 | 92 | private getDynamoDBKeys(property_id: string) { 93 | // Form the PK and SK from the property id. 94 | const components: string[] = property_id.split('/'); 95 | if (components.length < 4) { 96 | throw new Error(`Invalid propertyId ${property_id}`); 97 | } 98 | const country = components[0]; 99 | const city = components[1]; 100 | const street = components[2]; 101 | const number = components[3]; 102 | 103 | const pkDetails = `${country}#${city}`.replace(' ', '-').toLowerCase(); 104 | const PK = `PROPERTY#${pkDetails}`; 105 | const SK = `${street}#${number}`.replace(' ', '-').toLowerCase(); 106 | 107 | return { PK, SK }; 108 | } 109 | } 110 | 111 | const myFunction = new PublicationApprovedFunction(); 112 | export const lambdaHandler = myFunction.handler.bind(myFunction); 113 | -------------------------------------------------------------------------------- /unicorn_web/src/schema/unicorn_properties/publicationevaluationcompleted/AWSEvent.ts: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | export class AWSEvent { 4 | 'detail': T; 5 | 'detail_type': string; 6 | 'resources': string[]; 7 | 'id': string; 8 | 'source': string; 9 | 'time': Date; 10 | 'region': string; 11 | 'version': string; 12 | 'account': string; 13 | 14 | static discriminator: string | undefined = undefined; 15 | 16 | static detail = 'detail'; 17 | 18 | static genericType = 'T'; 19 | 20 | static attributeTypeMap: { 21 | name: string; 22 | baseName: string; 23 | type: string; 24 | }[] = [ 25 | { 26 | name: AWSEvent.detail, 27 | baseName: AWSEvent.detail, 28 | type: AWSEvent.genericType, 29 | }, 30 | { 31 | name: 'detail_type', 32 | baseName: 'detail-type', 33 | type: 'string', 34 | }, 35 | { 36 | name: 'resources', 37 | baseName: 'resources', 38 | type: 'Array', 39 | }, 40 | { 41 | name: 'id', 42 | baseName: 'id', 43 | type: 'string', 44 | }, 45 | { 46 | name: 'source', 47 | baseName: 'source', 48 | type: 'string', 49 | }, 50 | { 51 | name: 'time', 52 | baseName: 'time', 53 | type: 'Date', 54 | }, 55 | { 56 | name: 'region', 57 | baseName: 'region', 58 | type: 'string', 59 | }, 60 | { 61 | name: 'version', 62 | baseName: 'version', 63 | type: 'string', 64 | }, 65 | { 66 | name: 'account', 67 | baseName: 'account', 68 | type: 'string', 69 | }, 70 | ]; 71 | 72 | public static getAttributeTypeMap() { 73 | return AWSEvent.attributeTypeMap; 74 | } 75 | 76 | public static updateAttributeTypeMapDetail(type: string) { 77 | const index = AWSEvent.attributeTypeMap.indexOf({ 78 | name: AWSEvent.detail, 79 | baseName: AWSEvent.detail, 80 | type: AWSEvent.genericType, 81 | }); 82 | this.attributeTypeMap[index] = { 83 | name: AWSEvent.detail, 84 | baseName: AWSEvent.detail, 85 | type, 86 | }; 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /unicorn_web/src/schema/unicorn_properties/publicationevaluationcompleted/PublicationEvaluationCompleted.ts: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | export class PublicationEvaluationCompleted { 4 | 'evaluationResult': string; 5 | 'propertyId': string; 6 | 7 | private static discriminator: string | undefined = undefined; 8 | 9 | private static attributeTypeMap: { 10 | name: string; 11 | baseName: string; 12 | type: string; 13 | }[] = [ 14 | { 15 | name: 'evaluationResult', 16 | baseName: 'evaluation_result', 17 | type: 'string', 18 | }, 19 | { 20 | name: 'propertyId', 21 | baseName: 'property_id', 22 | type: 'string', 23 | }, 24 | ]; 25 | 26 | public static getAttributeTypeMap() { 27 | return PublicationEvaluationCompleted.attributeTypeMap; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /unicorn_web/src/schema/unicorn_properties/publicationevaluationcompleted/marshaller/Marshaller.ts: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | import { AWSEvent } from '../AWSEvent'; 4 | import { PublicationEvaluationCompleted } from '../PublicationEvaluationCompleted'; 5 | 6 | const primitives = [ 7 | 'string', 8 | 'boolean', 9 | 'double', 10 | 'integer', 11 | 'long', 12 | 'float', 13 | 'number', 14 | 'any', 15 | ]; 16 | 17 | const enumsMap: Record = {}; 18 | 19 | const typeMap: Record = { 20 | AWSEvent: AWSEvent, 21 | PublicationEvaluationCompleted: PublicationEvaluationCompleted, 22 | }; 23 | 24 | export class Marshaller { 25 | public static marshall(data: any, type: string) { 26 | if (data == undefined) { 27 | return data; 28 | } else if (primitives.indexOf(type.toLowerCase()) !== -1) { 29 | return data; 30 | } else if (type.lastIndexOf('Array<', 0) === 0) { 31 | // string.startsWith pre es6 32 | let subType: string = type.replace('Array<', ''); // Array => Type> 33 | subType = subType.substring(0, subType.length - 1); // Type> => Type 34 | const transformedData: any[] = []; 35 | for (const index in data) { 36 | const date = data[index]; 37 | transformedData.push(Marshaller.marshall(date, subType)); 38 | } 39 | return transformedData; 40 | } else if (type === 'Date') { 41 | return data.toString(); 42 | } else { 43 | if (enumsMap[type]) { 44 | return data; 45 | } 46 | if (!typeMap[type]) { 47 | // in case we dont know the type 48 | return data; 49 | } 50 | 51 | // get the map for the correct type. 52 | const attributeTypes = typeMap[type].getAttributeTypeMap(); 53 | const instance: Record = {}; 54 | for (const index in attributeTypes) { 55 | const attributeType = attributeTypes[index]; 56 | instance[attributeType.baseName] = Marshaller.marshall( 57 | data[attributeType.name], 58 | attributeType.type 59 | ); 60 | } 61 | return instance; 62 | } 63 | } 64 | 65 | public static unmarshalEvent(data: any, detailType: any) { 66 | typeMap['AWSEvent'].updateAttributeTypeMapDetail(detailType.name); 67 | return this.unmarshal(data, 'AWSEvent'); 68 | } 69 | 70 | public static unmarshal(data: any, type: string) { 71 | // polymorphism may change the actual type. 72 | type = Marshaller.findCorrectType(data, type); 73 | if (data == undefined) { 74 | return data; 75 | } else if (primitives.indexOf(type.toLowerCase()) !== -1) { 76 | return data; 77 | } else if (type.lastIndexOf('Array<', 0) === 0) { 78 | // string.startsWith pre es6 79 | let subType: string = type.replace('Array<', ''); // Array => Type> 80 | subType = subType.substring(0, subType.length - 1); // Type> => Type 81 | const transformedData: any[] = []; 82 | for (const index in data) { 83 | const date = data[index]; 84 | transformedData.push(Marshaller.unmarshal(date, subType)); 85 | } 86 | return transformedData; 87 | } else if (type === 'Date') { 88 | return new Date(data); 89 | } else { 90 | if (enumsMap[type]) { 91 | // is Enum 92 | return data; 93 | } 94 | 95 | if (!typeMap[type]) { 96 | // dont know the type 97 | return data; 98 | } 99 | const instance = new typeMap[type](); 100 | const attributeTypes = typeMap[type].getAttributeTypeMap(); 101 | for (const index in attributeTypes) { 102 | const attributeType = attributeTypes[index]; 103 | instance[attributeType.name] = Marshaller.unmarshal( 104 | data[attributeType.baseName], 105 | attributeType.type 106 | ); 107 | } 108 | return instance; 109 | } 110 | } 111 | 112 | private static findCorrectType(data: any, expectedType: string) { 113 | if (data == undefined) { 114 | return expectedType; 115 | } else if (primitives.indexOf(expectedType.toLowerCase()) !== -1) { 116 | return expectedType; 117 | } else if (expectedType === 'Date') { 118 | return expectedType; 119 | } else { 120 | if (enumsMap[expectedType]) { 121 | return expectedType; 122 | } 123 | 124 | if (!typeMap[expectedType]) { 125 | return expectedType; // unknown type 126 | } 127 | 128 | // Check the discriminator 129 | const discriminatorProperty = typeMap[expectedType].discriminator; 130 | if (discriminatorProperty == null) { 131 | return expectedType; // the type does not have a discriminator. use it. 132 | } else { 133 | if (data[discriminatorProperty]) { 134 | return data[discriminatorProperty]; // use the type given in the discriminator 135 | } else { 136 | return expectedType; // discriminator was not present (or an empty string) 137 | } 138 | } 139 | } 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /unicorn_web/src/search_service/powertools.ts: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | import { Metrics } from '@aws-lambda-powertools/metrics'; 4 | import { Logger } from '@aws-lambda-powertools/logger'; 5 | import type { LogLevel } from '@aws-lambda-powertools/logger/types'; 6 | import { Tracer } from '@aws-lambda-powertools/tracer'; 7 | 8 | const SERVICE_NAMESPACE = process.env.SERVICE_NAMESPACE ?? 'test_namespace'; 9 | 10 | const defaultValues = { 11 | region: process.env.AWS_REGION || 'N/A', 12 | executionEnv: process.env.AWS_EXECUTION_ENV || 'N/A', 13 | }; 14 | 15 | const logger = new Logger({ 16 | logLevel: (process.env.LOG_LEVEL || 'INFO') as LogLevel, 17 | persistentLogAttributes: { 18 | ...defaultValues, 19 | logger: { 20 | name: '@aws-lambda-powertools/logger', 21 | }, 22 | }, 23 | }); 24 | 25 | const metrics = new Metrics({ 26 | defaultDimensions: defaultValues, 27 | namespace: SERVICE_NAMESPACE, 28 | }); 29 | 30 | const tracer = new Tracer(); 31 | 32 | export { metrics, logger, tracer }; 33 | -------------------------------------------------------------------------------- /unicorn_web/tests/events/eventbridge/put_event_publication_evaluation_completed.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "Source": "unicorn.properties", 4 | "Detail": "{\"property_id\":\"usa/anytown/main-street/111\",\"evaluation_result\": \"APPROVED\"}", 5 | "DetailType": "PublicationEvaluationCompleted", 6 | "EventBusName": "UnicornWebBus-local" 7 | } 8 | ] -------------------------------------------------------------------------------- /unicorn_web/tests/integration/requestApproval.test.ts: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | import { 4 | initialiseDatabase, 5 | findOutputValue, 6 | clearDatabase, 7 | getCloudWatchLogsValues, 8 | sleep, 9 | } from './helper'; 10 | 11 | describe('Testing approval requests', () => { 12 | let apiUrl: string; 13 | 14 | beforeAll(async () => { 15 | // Clear DB 16 | await clearDatabase(); 17 | // Load data 18 | await initialiseDatabase(); 19 | // Find API Endpoint 20 | apiUrl = await findOutputValue( 21 | 'uni-prop-local-web', 22 | 'UnicornWebRestApiUrl' 23 | ); 24 | }, 10000); 25 | 26 | afterAll(async () => { 27 | // Clear DB 28 | await clearDatabase(); 29 | }); 30 | 31 | it('Should a confirm the approval request and fire a eventbridge event', async () => { 32 | const response = await fetch(`${apiUrl}request_approval`, { 33 | method: 'POST', 34 | headers: { 'content-type': 'application/json' }, 35 | body: '{"property_id":"USA/Anytown/main-street/111"}', 36 | }); 37 | expect(response.status).toBe(200); 38 | const json = await response.json(); 39 | expect(json).toEqual({ message: 'OK' }); 40 | await sleep(5000); 41 | const event = await getCloudWatchLogsValues( 42 | 'USA/Anytown/main-street/111' 43 | ).next(); 44 | expect(event.value['detail-type']).toEqual('PublicationApprovalRequested'); 45 | expect(event.value['detail'].property_id).toEqual( 46 | 'USA/Anytown/main-street/111' 47 | ); 48 | expect(event.value['detail'].status).toEqual('PENDING'); 49 | }, 20000); 50 | }); 51 | -------------------------------------------------------------------------------- /unicorn_web/tests/integration/search.test.ts: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | import { clearDatabase, findOutputValue } from './helper'; 4 | import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; 5 | import { DynamoDBDocumentClient, PutCommand } from '@aws-sdk/lib-dynamodb'; 6 | import ApprovedProperty from '../../data/approved_property.json'; 7 | 8 | describe('Testing approved property listing searches', () => { 9 | let apiUrl: string; 10 | 11 | beforeAll(async () => { 12 | // Clear db 13 | await clearDatabase(); 14 | // Create an approved property listing 15 | const tableName = await findOutputValue( 16 | 'uni-prop-local-web', 17 | 'UnicornWebTableName' 18 | ); 19 | const docClient = DynamoDBDocumentClient.from( 20 | new DynamoDBClient({ region: process.env.AWS_DEFAULT_REGION }) 21 | ); 22 | await docClient.send( 23 | new PutCommand({ 24 | TableName: tableName, 25 | Item: ApprovedProperty, 26 | }) 27 | ); 28 | // Find API Endpoint 29 | apiUrl = await findOutputValue( 30 | 'uni-prop-local-web', 31 | 'UnicornWebRestApiUrl' 32 | ); 33 | }, 10000); 34 | 35 | afterAll(async () => { 36 | await clearDatabase(); 37 | }); 38 | 39 | it('Should show the approved property listing in search by city', async () => { 40 | const response = await fetch(`${apiUrl}search/au/anytown`, { 41 | method: 'GET', 42 | }); 43 | const json = await response.json(); 44 | expect(json).toEqual([ 45 | { 46 | city: 'Anytown', 47 | contract: 'sale', 48 | country: 'AU', 49 | currency: 'SPL', 50 | listprice: 200, 51 | number: 1337, 52 | description: 53 | 'This classic Anytown estate comes with a covetable lake view. The romantic and comfortable backyard is the perfect setting for unicorn get-togethers. The open concept Main Stable is fully equipped with all the desired amenities. Second floor features 6 straw bales including large Rainbow Suite with private training pool terrace and Jr Sparkles Suite.', 54 | status: 'APPROVED', 55 | street: 'Main Street', 56 | }, 57 | ]); 58 | }); 59 | 60 | it('Should show the approved property listing in search by street', async () => { 61 | const response = await fetch(`${apiUrl}search/au/anytown/main-street`, { 62 | method: 'GET', 63 | }); 64 | const json = await response.json(); 65 | expect(json).toEqual([ 66 | { 67 | city: 'Anytown', 68 | contract: 'sale', 69 | country: 'AU', 70 | currency: 'SPL', 71 | listprice: 200, 72 | number: 1337, 73 | description: 74 | 'This classic Anytown estate comes with a covetable lake view. The romantic and comfortable backyard is the perfect setting for unicorn get-togethers. The open concept Main Stable is fully equipped with all the desired amenities. Second floor features 6 straw bales including large Rainbow Suite with private training pool terrace and Jr Sparkles Suite.', 75 | status: 'APPROVED', 76 | street: 'Main Street', 77 | }, 78 | ]); 79 | }); 80 | 81 | it('Should show the approved property listing in search by address', async () => { 82 | const response = await fetch( 83 | `${apiUrl}properties/au/anytown/main-street/1337`, 84 | { 85 | method: 'GET', 86 | } 87 | ); 88 | const json = await response.json(); 89 | expect(json).toEqual({ 90 | city: 'Anytown', 91 | contract: 'sale', 92 | country: 'AU', 93 | currency: 'SPL', 94 | listprice: 200, 95 | number: 1337, 96 | description: 97 | 'This classic Anytown estate comes with a covetable lake view. The romantic and comfortable backyard is the perfect setting for unicorn get-togethers. The open concept Main Stable is fully equipped with all the desired amenities. Second floor features 6 straw bales including large Rainbow Suite with private training pool terrace and Jr Sparkles Suite.', 98 | status: 'APPROVED', 99 | street: 'Main Street', 100 | }); 101 | }); 102 | }); 103 | -------------------------------------------------------------------------------- /unicorn_web/tests/integration/transformations/ddb_contract.jq: -------------------------------------------------------------------------------- 1 | .Item | { 2 | property_id: .property_id.S, 3 | contract_id: .contract_id.S, 4 | seller_name: .seller_name.S, 5 | address: { 6 | country: .address.M.country.S, 7 | number: .address.M.number.N, 8 | city: .address.M.city.S, 9 | street: .address.M.street.S, 10 | }, 11 | contract_status: .contract_status.S, 12 | contract_created: .contract_created.S, 13 | contract_last_modified_on: .contract_last_modified_on.S 14 | } | del(..|nulls) 15 | -------------------------------------------------------------------------------- /unicorn_web/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2020", 4 | "strict": true, 5 | "types": ["node", "jest"], 6 | "baseUrl": "./", 7 | "paths": { 8 | "src/*": ["src/*"], 9 | "tests/*": ["tests/*"] 10 | }, 11 | "resolveJsonModule": true, 12 | "preserveConstEnums": true, 13 | "noEmit": true, 14 | "sourceMap": false, 15 | "module":"es2015", 16 | "moduleResolution":"node", 17 | "esModuleInterop": true, 18 | "skipLibCheck": true, 19 | "forceConsistentCasingInFileNames": true, 20 | "experimentalDecorators": true 21 | }, 22 | "typeAcquisition": { "include": ["jest"] }, 23 | "exclude": ["node_modules"] 24 | } --------------------------------------------------------------------------------