├── .github ├── pull_request_template.md └── workflows │ ├── main-audit.yaml │ ├── main-pr-verify-title.yml │ ├── main-pr-verify.yaml │ └── main-publish.yaml ├── .gitignore ├── .npmrc ├── .nvmrc ├── .prettierrc ├── LICENCE ├── README.md ├── assets └── diagram.drawio.png ├── docker-compose.yml ├── dockerfile ├── doppler.yaml ├── eslint.config.mjs ├── jest.config.js ├── jest.setup.js ├── package.json ├── src ├── __snapshots__ │ └── index.spec.ts.snap ├── contracts │ ├── linkedin-profile.request.ts │ └── linkedin-profile.response.ts ├── domain │ ├── errors │ │ ├── invalid-mac.error.ts │ │ └── max-retries.error.ts │ ├── linkedin-profile.ts │ └── logger-context.ts ├── get-linkedin-token.ts ├── index.spec.ts ├── index.ts ├── launch-sqs-consumer.ts ├── launch.ts ├── mappers │ ├── linkedin-profile.request.mapper.ts │ └── linkedin-profile.response.mapper.ts ├── services │ ├── linkedin-api.client.spec.ts │ ├── linkedin-api.client.ts │ ├── linkedin-profile.service.spec.ts │ ├── linkedin-profile.service.ts │ ├── queue.client.spec.ts │ └── queue.client.ts ├── test │ └── mocks │ │ ├── linkedin-profile.mocks.ts │ │ └── sqs.mocks.ts └── util │ ├── __snapshots__ │ └── environment.spec.ts.snap │ ├── date.spec.ts │ ├── date.ts │ ├── environment.spec.ts │ ├── environment.ts │ ├── logger.ts │ └── validation.ts ├── tsconfig.json └── yarn.lock /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## 🧐 Context 2 | 3 | > [!NOTE] 4 | > Relevant links: 5 | > 6 | > - Jira or other ticket reference: ASG-XXX 7 | 8 | Provide a brief, clear summary of the changes made in this PR. 9 | 10 | ## 🤙 How to test 11 | 12 | Steps to manually test this change: 13 | 14 | 1. Step 1 15 | 2. Step 2 16 | 3. Expected outcome 17 | 18 | ## 🚨 Important considerations (optional) 19 | 20 | > [!WARNING] 21 | > - Are there new environment variables? 22 | > - Any breaking changes? 23 | > - Additional dependencies or migrations required? 24 | 25 | Be sure to highlight any critical changes that could impact the environment or require special attention during deployment. 26 | 27 | ## 📝 Detailed explanation (optional) 28 | 29 | Provide a more in-depth explanation of the changes if necessary. Include diagrams, examples, or links to additional documentation for clarity. 30 | -------------------------------------------------------------------------------- /.github/workflows/main-audit.yaml: -------------------------------------------------------------------------------- 1 | # .github/workflows/stale-branches.yml 2 | 3 | name: 'Audit Dependencies Periodically' 4 | 5 | on: 6 | schedule: 7 | - cron: '0 7 * * 1' # Every Monday at 7am 8 | workflow_dispatch: 9 | 10 | concurrency: 11 | group: ${{ github.workflow }}-${{ github.ref }} 12 | cancel-in-progress: true 13 | 14 | jobs: 15 | build: 16 | name: Audit Dependencies 17 | timeout-minutes: 10 18 | runs-on: ubuntu-latest 19 | 20 | steps: 21 | - name: Check out code 22 | uses: actions/checkout@v4 23 | 24 | - name: Setup Node.js environment 25 | uses: actions/setup-node@v4 26 | with: 27 | node-version: 20.17.0 28 | 29 | - name: Install dependencies 30 | run: npm install 31 | 32 | - name: Audit critical vulnerabilities 33 | run: npm run audit:critical 34 | -------------------------------------------------------------------------------- /.github/workflows/main-pr-verify-title.yml: -------------------------------------------------------------------------------- 1 | name: "Main PR: verify PR title" 2 | on: 3 | pull_request: 4 | branches: [main] 5 | types: 6 | - opened 7 | - edited 8 | - synchronize 9 | 10 | concurrency: 11 | group: ${{ github.workflow }}-${{ github.ref }} 12 | cancel-in-progress: true 13 | 14 | jobs: 15 | PR-title-verify: 16 | name: PR title verify 17 | runs-on: ubuntu-latest 18 | steps: 19 | - name: Verify 20 | uses: Slashgear/action-check-pr-title@v4.3.0 21 | with: 22 | regexp: '(feat|fix|docs|build|chore|ci|style|refactor|perf|test): .+ \(((ASG-\d+ (#IN_PROGRESS|#DONE))|NOJIRA)\)' 23 | helpMessage: 'Ex. "feat: my changes description (ASG-2 #DONE)" OR "feat: my changes description (NOJIRA)"' 24 | -------------------------------------------------------------------------------- /.github/workflows/main-pr-verify.yaml: -------------------------------------------------------------------------------- 1 | name: 'Main PR: verify' 2 | 3 | on: 4 | pull_request: 5 | branches: [main] 6 | 7 | concurrency: 8 | group: ${{ github.workflow }}-${{ github.ref }} 9 | cancel-in-progress: true 10 | 11 | jobs: 12 | build: 13 | name: Build and Test 14 | timeout-minutes: 10 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - name: Check out code 19 | uses: actions/checkout@v4 20 | 21 | - name: Setup Node.js environment 22 | uses: actions/setup-node@v4 23 | with: 24 | node-version: 20.17.0 25 | 26 | - name: Install dependencies 27 | run: npm install 28 | 29 | - name: Audit critical dependency vulnerabilities 30 | run: npm run audit:critical 31 | 32 | - name: Lint 33 | run: npm run lint 34 | 35 | - name: Build 36 | run: npm run build 37 | 38 | - name: Tests 39 | run: npm run test 40 | -------------------------------------------------------------------------------- /.github/workflows/main-publish.yaml: -------------------------------------------------------------------------------- 1 | name: '🚀 Publish Linkedin API Lambda image' 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: 7 | - main 8 | 9 | jobs: 10 | publish-image: 11 | name: Push image to ECR and update Lambda 12 | timeout-minutes: 10 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - name: Check out code 17 | uses: actions/checkout@v4 18 | 19 | - name: Setup Node.js environment 20 | uses: actions/setup-node@v4 21 | with: 22 | node-version: 20.17.0 23 | 24 | - name: Configure AWS Credentials 25 | uses: aws-actions/configure-aws-credentials@v1 26 | with: 27 | # Created the Secrets Under the Repo only with These Variables 28 | aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} 29 | aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} 30 | aws-region: ${{ secrets.AWS_REGION }} 31 | 32 | - name: Build image 33 | run: | 34 | aws ecr get-login-password --region ${{ secrets.AWS_REGION }} | docker login --username AWS --password-stdin ${{ secrets.ECR_ACCOUNT_ID }}.dkr.ecr.${{ secrets.AWS_REGION }}.amazonaws.com 35 | COMPOSE_DOCKER_CLI_BUILD=1 DOCKER_BUILDKIT=1 docker compose -f docker-compose.yml build linkedin-api-lambda 36 | 37 | - name: Push image 38 | run: | 39 | docker tag linkedin-api-lambda:latest ${{ secrets.ECR_ACCOUNT_ID }}.dkr.ecr.${{ secrets.AWS_REGION }}.amazonaws.com/linkedin-api-lambda:v1.${GITHUB_RUN_NUMBER} 40 | docker tag linkedin-api-lambda:latest ${{ secrets.ECR_ACCOUNT_ID }}.dkr.ecr.${{ secrets.AWS_REGION }}.amazonaws.com/linkedin-api-lambda:latest 41 | docker push ${{ secrets.ECR_ACCOUNT_ID }}.dkr.ecr.${{ secrets.AWS_REGION }}.amazonaws.com/linkedin-api-lambda --all-tags 42 | aws lambda update-function-code --function-name linkedin-api-lambda --image-uri ${{ secrets.ECR_ACCOUNT_ID }}.dkr.ecr.${{ secrets.AWS_REGION }}.amazonaws.com/linkedin-api-lambda:v1.${GITHUB_RUN_NUMBER} 43 | 44 | previous-step-fail: 45 | runs-on: ubuntu-latest 46 | if: ${{ github.event.workflow_run.conclusion == 'failure' }} 47 | steps: 48 | - run: echo "CI failed - not pushing manfredite-airtable-update image to ECR" 49 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Node.js dependencies 2 | node_modules/ 3 | npm-debug.log* 4 | 5 | # Operating system and environment-specific files 6 | .DS_Store 7 | Thumbs.db 8 | 9 | # Environment variables 10 | .env* 11 | !.env.example 12 | 13 | # Build output directories 14 | /dist 15 | 16 | # Test coverage results 17 | coverage/ 18 | 19 | # Temporary or configuration files for code editors 20 | *.sublime-workspace 21 | .idea/ 22 | .vscode/ 23 | *.swp 24 | *.tmp 25 | *.bak 26 | 27 | # Test and coverage system output 28 | .nyc_output 29 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | save-exact=true 2 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v20.17.0 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "printWidth": 150, 4 | "importOrderSeparation": true, 5 | "importOrderSortSpecifiers": true, 6 | "trailingComma": "none" 7 | } 8 | -------------------------------------------------------------------------------- /LICENCE: -------------------------------------------------------------------------------- 1 | Attribution-ShareAlike 4.0 International 2 | 3 | ======================================================================= 4 | 5 | Creative Commons Corporation ("Creative Commons") is not a law firm and 6 | does not provide legal services or legal advice. Distribution of 7 | Creative Commons public licenses does not create a lawyer-client or 8 | other relationship. Creative Commons makes its licenses and related 9 | information available on an "as-is" basis. Creative Commons gives no 10 | warranties regarding its licenses, any material licensed under their 11 | terms and conditions, or any related information. Creative Commons 12 | disclaims all liability for damages resulting from their use to the 13 | fullest extent possible. 14 | 15 | Using Creative Commons Public Licenses 16 | 17 | Creative Commons public licenses provide a standard set of terms and 18 | conditions that creators and other rights holders may use to share 19 | original works of authorship and other material subject to copyright 20 | and certain other rights specified in the public license below. The 21 | following considerations are for informational purposes only, are not 22 | exhaustive, and do not form part of our licenses. 23 | 24 | Considerations for licensors: Our public licenses are 25 | intended for use by those authorized to give the public 26 | permission to use material in ways otherwise restricted by 27 | copyright and certain other rights. Our licenses are 28 | irrevocable. Licensors should read and understand the terms 29 | and conditions of the license they choose before applying it. 30 | Licensors should also secure all rights necessary before 31 | applying our licenses so that the public can reuse the 32 | material as expected. Licensors should clearly mark any 33 | material not subject to the license. This includes other CC- 34 | licensed material, or material used under an exception or 35 | limitation to copyright. More considerations for licensors: 36 | wiki.creativecommons.org/Considerations_for_licensors 37 | 38 | Considerations for the public: By using one of our public 39 | licenses, a licensor grants the public permission to use the 40 | licensed material under specified terms and conditions. If 41 | the licensor's permission is not necessary for any reason--for 42 | example, because of any applicable exception or limitation to 43 | copyright--then that use is not regulated by the license. Our 44 | licenses grant only permissions under copyright and certain 45 | other rights that a licensor has authority to grant. Use of 46 | the licensed material may still be restricted for other 47 | reasons, including because others have copyright or other 48 | rights in the material. A licensor may make special requests, 49 | such as asking that all changes be marked or described. 50 | Although not required by our licenses, you are encouraged to 51 | respect those requests where reasonable. More considerations 52 | for the public: 53 | wiki.creativecommons.org/Considerations_for_licensees 54 | 55 | ======================================================================= 56 | 57 | Creative Commons Attribution-ShareAlike 4.0 International Public 58 | License 59 | 60 | By exercising the Licensed Rights (defined below), You accept and agree 61 | to be bound by the terms and conditions of this Creative Commons 62 | Attribution-ShareAlike 4.0 International Public License ("Public 63 | License"). To the extent this Public License may be interpreted as a 64 | contract, You are granted the Licensed Rights in consideration of Your 65 | acceptance of these terms and conditions, and the Licensor grants You 66 | such rights in consideration of benefits the Licensor receives from 67 | making the Licensed Material available under these terms and 68 | conditions. 69 | 70 | 71 | Section 1 -- Definitions. 72 | 73 | a. Adapted Material means material subject to Copyright and Similar 74 | Rights that is derived from or based upon the Licensed Material 75 | and in which the Licensed Material is translated, altered, 76 | arranged, transformed, or otherwise modified in a manner requiring 77 | permission under the Copyright and Similar Rights held by the 78 | Licensor. For purposes of this Public License, where the Licensed 79 | Material is a musical work, performance, or sound recording, 80 | Adapted Material is always produced where the Licensed Material is 81 | synched in timed relation with a moving image. 82 | 83 | b. Adapter's License means the license You apply to Your Copyright 84 | and Similar Rights in Your contributions to Adapted Material in 85 | accordance with the terms and conditions of this Public License. 86 | 87 | c. BY-SA Compatible License means a license listed at 88 | creativecommons.org/compatiblelicenses, approved by Creative 89 | Commons as essentially the equivalent of this Public License. 90 | 91 | d. Copyright and Similar Rights means copyright and/or similar rights 92 | closely related to copyright including, without limitation, 93 | performance, broadcast, sound recording, and Sui Generis Database 94 | Rights, without regard to how the rights are labeled or 95 | categorized. For purposes of this Public License, the rights 96 | specified in Section 2(b)(1)-(2) are not Copyright and Similar 97 | Rights. 98 | 99 | e. Effective Technological Measures means those measures that, in the 100 | absence of proper authority, may not be circumvented under laws 101 | fulfilling obligations under Article 11 of the WIPO Copyright 102 | Treaty adopted on December 20, 1996, and/or similar international 103 | agreements. 104 | 105 | f. Exceptions and Limitations means fair use, fair dealing, and/or 106 | any other exception or limitation to Copyright and Similar Rights 107 | that applies to Your use of the Licensed Material. 108 | 109 | g. License Elements means the license attributes listed in the name 110 | of a Creative Commons Public License. The License Elements of this 111 | Public License are Attribution and ShareAlike. 112 | 113 | h. Licensed Material means the artistic or literary work, database, 114 | or other material to which the Licensor applied this Public 115 | License. 116 | 117 | i. Licensed Rights means the rights granted to You subject to the 118 | terms and conditions of this Public License, which are limited to 119 | all Copyright and Similar Rights that apply to Your use of the 120 | Licensed Material and that the Licensor has authority to license. 121 | 122 | j. Licensor means the individual(s) or entity(ies) granting rights 123 | under this Public License. 124 | 125 | k. Share means to provide material to the public by any means or 126 | process that requires permission under the Licensed Rights, such 127 | as reproduction, public display, public performance, distribution, 128 | dissemination, communication, or importation, and to make material 129 | available to the public including in ways that members of the 130 | public may access the material from a place and at a time 131 | individually chosen by them. 132 | 133 | l. Sui Generis Database Rights means rights other than copyright 134 | resulting from Directive 96/9/EC of the European Parliament and of 135 | the Council of 11 March 1996 on the legal protection of databases, 136 | as amended and/or succeeded, as well as other essentially 137 | equivalent rights anywhere in the world. 138 | 139 | m. You means the individual or entity exercising the Licensed Rights 140 | under this Public License. Your has a corresponding meaning. 141 | 142 | 143 | Section 2 -- Scope. 144 | 145 | a. License grant. 146 | 147 | 1. Subject to the terms and conditions of this Public License, 148 | the Licensor hereby grants You a worldwide, royalty-free, 149 | non-sublicensable, non-exclusive, irrevocable license to 150 | exercise the Licensed Rights in the Licensed Material to: 151 | 152 | a. reproduce and Share the Licensed Material, in whole or 153 | in part; and 154 | 155 | b. produce, reproduce, and Share Adapted Material. 156 | 157 | 2. Exceptions and Limitations. For the avoidance of doubt, where 158 | Exceptions and Limitations apply to Your use, this Public 159 | License does not apply, and You do not need to comply with 160 | its terms and conditions. 161 | 162 | 3. Term. The term of this Public License is specified in Section 163 | 6(a). 164 | 165 | 4. Media and formats; technical modifications allowed. The 166 | Licensor authorizes You to exercise the Licensed Rights in 167 | all media and formats whether now known or hereafter created, 168 | and to make technical modifications necessary to do so. The 169 | Licensor waives and/or agrees not to assert any right or 170 | authority to forbid You from making technical modifications 171 | necessary to exercise the Licensed Rights, including 172 | technical modifications necessary to circumvent Effective 173 | Technological Measures. For purposes of this Public License, 174 | simply making modifications authorized by this Section 2(a) 175 | (4) never produces Adapted Material. 176 | 177 | 5. Downstream recipients. 178 | 179 | a. Offer from the Licensor -- Licensed Material. Every 180 | recipient of the Licensed Material automatically 181 | receives an offer from the Licensor to exercise the 182 | Licensed Rights under the terms and conditions of this 183 | Public License. 184 | 185 | b. Additional offer from the Licensor -- Adapted Material. 186 | Every recipient of Adapted Material from You 187 | automatically receives an offer from the Licensor to 188 | exercise the Licensed Rights in the Adapted Material 189 | under the conditions of the Adapter's License You apply. 190 | 191 | c. No downstream restrictions. You may not offer or impose 192 | any additional or different terms or conditions on, or 193 | apply any Effective Technological Measures to, the 194 | Licensed Material if doing so restricts exercise of the 195 | Licensed Rights by any recipient of the Licensed 196 | Material. 197 | 198 | 6. No endorsement. Nothing in this Public License constitutes or 199 | may be construed as permission to assert or imply that You 200 | are, or that Your use of the Licensed Material is, connected 201 | with, or sponsored, endorsed, or granted official status by, 202 | the Licensor or others designated to receive attribution as 203 | provided in Section 3(a)(1)(A)(i). 204 | 205 | b. Other rights. 206 | 207 | 1. Moral rights, such as the right of integrity, are not 208 | licensed under this Public License, nor are publicity, 209 | privacy, and/or other similar personality rights; however, to 210 | the extent possible, the Licensor waives and/or agrees not to 211 | assert any such rights held by the Licensor to the limited 212 | extent necessary to allow You to exercise the Licensed 213 | Rights, but not otherwise. 214 | 215 | 2. Patent and trademark rights are not licensed under this 216 | Public License. 217 | 218 | 3. To the extent possible, the Licensor waives any right to 219 | collect royalties from You for the exercise of the Licensed 220 | Rights, whether directly or through a collecting society 221 | under any voluntary or waivable statutory or compulsory 222 | licensing scheme. In all other cases the Licensor expressly 223 | reserves any right to collect such royalties. 224 | 225 | 226 | Section 3 -- License Conditions. 227 | 228 | Your exercise of the Licensed Rights is expressly made subject to the 229 | following conditions. 230 | 231 | a. Attribution. 232 | 233 | 1. If You Share the Licensed Material (including in modified 234 | form), You must: 235 | 236 | a. retain the following if it is supplied by the Licensor 237 | with the Licensed Material: 238 | 239 | i. identification of the creator(s) of the Licensed 240 | Material and any others designated to receive 241 | attribution, in any reasonable manner requested by 242 | the Licensor (including by pseudonym if 243 | designated); 244 | 245 | ii. a copyright notice; 246 | 247 | iii. a notice that refers to this Public License; 248 | 249 | iv. a notice that refers to the disclaimer of 250 | warranties; 251 | 252 | v. a URI or hyperlink to the Licensed Material to the 253 | extent reasonably practicable; 254 | 255 | b. indicate if You modified the Licensed Material and 256 | retain an indication of any previous modifications; and 257 | 258 | c. indicate the Licensed Material is licensed under this 259 | Public License, and include the text of, or the URI or 260 | hyperlink to, this Public License. 261 | 262 | 2. You may satisfy the conditions in Section 3(a)(1) in any 263 | reasonable manner based on the medium, means, and context in 264 | which You Share the Licensed Material. For example, it may be 265 | reasonable to satisfy the conditions by providing a URI or 266 | hyperlink to a resource that includes the required 267 | information. 268 | 269 | 3. If requested by the Licensor, You must remove any of the 270 | information required by Section 3(a)(1)(A) to the extent 271 | reasonably practicable. 272 | 273 | b. ShareAlike. 274 | 275 | In addition to the conditions in Section 3(a), if You Share 276 | Adapted Material You produce, the following conditions also apply. 277 | 278 | 1. The Adapter's License You apply must be a Creative Commons 279 | license with the same License Elements, this version or 280 | later, or a BY-SA Compatible License. 281 | 282 | 2. You must include the text of, or the URI or hyperlink to, the 283 | Adapter's License You apply. You may satisfy this condition 284 | in any reasonable manner based on the medium, means, and 285 | context in which You Share Adapted Material. 286 | 287 | 3. You may not offer or impose any additional or different terms 288 | or conditions on, or apply any Effective Technological 289 | Measures to, Adapted Material that restrict exercise of the 290 | rights granted under the Adapter's License You apply. 291 | 292 | 293 | Section 4 -- Sui Generis Database Rights. 294 | 295 | Where the Licensed Rights include Sui Generis Database Rights that 296 | apply to Your use of the Licensed Material: 297 | 298 | a. for the avoidance of doubt, Section 2(a)(1) grants You the right 299 | to extract, reuse, reproduce, and Share all or a substantial 300 | portion of the contents of the database; 301 | 302 | b. if You include all or a substantial portion of the database 303 | contents in a database in which You have Sui Generis Database 304 | Rights, then the database in which You have Sui Generis Database 305 | Rights (but not its individual contents) is Adapted Material, 306 | including for purposes of Section 3(b); and 307 | 308 | c. You must comply with the conditions in Section 3(a) if You Share 309 | all or a substantial portion of the contents of the database. 310 | 311 | For the avoidance of doubt, this Section 4 supplements and does not 312 | replace Your obligations under this Public License where the Licensed 313 | Rights include other Copyright and Similar Rights. 314 | 315 | 316 | Section 5 -- Disclaimer of Warranties and Limitation of Liability. 317 | 318 | a. UNLESS OTHERWISE SEPARATELY UNDERTAKEN BY THE LICENSOR, TO THE 319 | EXTENT POSSIBLE, THE LICENSOR OFFERS THE LICENSED MATERIAL AS-IS 320 | AND AS-AVAILABLE, AND MAKES NO REPRESENTATIONS OR WARRANTIES OF 321 | ANY KIND CONCERNING THE LICENSED MATERIAL, WHETHER EXPRESS, 322 | IMPLIED, STATUTORY, OR OTHER. THIS INCLUDES, WITHOUT LIMITATION, 323 | WARRANTIES OF TITLE, MERCHANTABILITY, FITNESS FOR A PARTICULAR 324 | PURPOSE, NON-INFRINGEMENT, ABSENCE OF LATENT OR OTHER DEFECTS, 325 | ACCURACY, OR THE PRESENCE OR ABSENCE OF ERRORS, WHETHER OR NOT 326 | KNOWN OR DISCOVERABLE. WHERE DISCLAIMERS OF WARRANTIES ARE NOT 327 | ALLOWED IN FULL OR IN PART, THIS DISCLAIMER MAY NOT APPLY TO YOU. 328 | 329 | b. TO THE EXTENT POSSIBLE, IN NO EVENT WILL THE LICENSOR BE LIABLE 330 | TO YOU ON ANY LEGAL THEORY (INCLUDING, WITHOUT LIMITATION, 331 | NEGLIGENCE) OR OTHERWISE FOR ANY DIRECT, SPECIAL, INDIRECT, 332 | INCIDENTAL, CONSEQUENTIAL, PUNITIVE, EXEMPLARY, OR OTHER LOSSES, 333 | COSTS, EXPENSES, OR DAMAGES ARISING OUT OF THIS PUBLIC LICENSE OR 334 | USE OF THE LICENSED MATERIAL, EVEN IF THE LICENSOR HAS BEEN 335 | ADVISED OF THE POSSIBILITY OF SUCH LOSSES, COSTS, EXPENSES, OR 336 | DAMAGES. WHERE A LIMITATION OF LIABILITY IS NOT ALLOWED IN FULL OR 337 | IN PART, THIS LIMITATION MAY NOT APPLY TO YOU. 338 | 339 | c. The disclaimer of warranties and limitation of liability provided 340 | above shall be interpreted in a manner that, to the extent 341 | possible, most closely approximates an absolute disclaimer and 342 | waiver of all liability. 343 | 344 | 345 | Section 6 -- Term and Termination. 346 | 347 | a. This Public License applies for the term of the Copyright and 348 | Similar Rights licensed here. However, if You fail to comply with 349 | this Public License, then Your rights under this Public License 350 | terminate automatically. 351 | 352 | b. Where Your right to use the Licensed Material has terminated under 353 | Section 6(a), it reinstates: 354 | 355 | 1. automatically as of the date the violation is cured, provided 356 | it is cured within 30 days of Your discovery of the 357 | violation; or 358 | 359 | 2. upon express reinstatement by the Licensor. 360 | 361 | For the avoidance of doubt, this Section 6(b) does not affect any 362 | right the Licensor may have to seek remedies for Your violations 363 | of this Public License. 364 | 365 | c. For the avoidance of doubt, the Licensor may also offer the 366 | Licensed Material under separate terms or conditions or stop 367 | distributing the Licensed Material at any time; however, doing so 368 | will not terminate this Public License. 369 | 370 | d. Sections 1, 5, 6, 7, and 8 survive termination of this Public 371 | License. 372 | 373 | 374 | Section 7 -- Other Terms and Conditions. 375 | 376 | a. The Licensor shall not be bound by any additional or different 377 | terms or conditions communicated by You unless expressly agreed. 378 | 379 | b. Any arrangements, understandings, or agreements regarding the 380 | Licensed Material not stated herein are separate from and 381 | independent of the terms and conditions of this Public License. 382 | 383 | 384 | Section 8 -- Interpretation. 385 | 386 | a. For the avoidance of doubt, this Public License does not, and 387 | shall not be interpreted to, reduce, limit, restrict, or impose 388 | conditions on any use of the Licensed Material that could lawfully 389 | be made without permission under this Public License. 390 | 391 | b. To the extent possible, if any provision of this Public License is 392 | deemed unenforceable, it shall be automatically reformed to the 393 | minimum extent necessary to make it enforceable. If the provision 394 | cannot be reformed, it shall be severed from this Public License 395 | without affecting the enforceability of the remaining terms and 396 | conditions. 397 | 398 | c. No term or condition of this Public License will be waived and no 399 | failure to comply consented to unless expressly agreed to by the 400 | Licensor. 401 | 402 | d. Nothing in this Public License constitutes or may be interpreted 403 | as a limitation upon, or waiver of, any privileges and immunities 404 | that apply to the Licensor or You, including from the legal 405 | processes of any jurisdiction or authority. 406 | 407 | 408 | ======================================================================= 409 | 410 | Creative Commons is not a party to its public licenses. 411 | Notwithstanding, Creative Commons may elect to apply one of its public 412 | licenses to material it publishes and in those instances will be 413 | considered the “Licensor.” The text of the Creative Commons public 414 | licenses is dedicated to the public domain under the CC0 Public Domain 415 | Dedication. Except for the limited purpose of indicating that material 416 | is shared under a Creative Commons public license or as otherwise 417 | permitted by the Creative Commons policies published at 418 | creativecommons.org/policies, Creative Commons does not authorize the 419 | use of the trademark "Creative Commons" or any other trademark or logo 420 | of Creative Commons without its prior written consent including, 421 | without limitation, in connection with any unauthorized modifications 422 | to any of its public licenses or any other arrangements, 423 | understandings, or agreements concerning use of licensed material. For 424 | the avoidance of doubt, this paragraph does not form part of the public 425 | licenses. 426 | 427 | Creative Commons may be contacted at creativecommons.org. 428 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Linkedin API Lambda 2 | 3 | This Lambda function processes requests to import LinkedIn profiles. It uses LinkedIn's [Member Data Portability (Member) API](https://learn.microsoft.com/en-us/linkedin/dma/member-data-portability/member-data-portability-member/?view=li-dma-data-portability-2024-08) to retrieve relevant profile data and returns a CV in [MAc format](https://github.com/getmanfred/mac). 4 | 5 | _Note: This API is available only to LinkedIn users registered in the European Union._ 6 | 7 | ![Flow](./assets/diagram.drawio.png) 8 | 9 | # Environment variables 10 | 11 | Environment variables must be defined in the .env file. The full list of environment variables can be found in [environment.ts](./src/util/environment.ts). Below, we highlight the most important variables for running local tests: 12 | 13 | - `LOCAL_LINKEDIN_API_TOKEN=YOUR_LINKEDIN_API_TOKEN`: Your LinkedIn user token for easily testing this Lambda locally. 14 | - `LOGGER_CONSOLE=true`: Enables the logger in a human-friendly format instead of JSON format. 15 | 16 | ## How to retrieve a valid Linkedin token 17 | 18 | - `LOCAL_LINKEDIN_CLIENT_ID` and `LOCAL_LINKEDIN_CLIENT_SECRET` must be configured properly (see next section). Then type: 19 | 20 | ```bash 21 | yarn dev:token 22 | ``` 23 | 24 | And copy token value inside `LOCAL_LINKEDIN_CLIENT_SECRET` environment variable. 25 | 26 | # How to develop 27 | 28 | Follow these steps to set up the development environment and run essential tasks for the project: 29 | 30 | - Install all dependencies using `nvm` and `yarn`: 31 | 32 | ```bash 33 | nvm use 34 | yarn install 35 | ``` 36 | 37 | - If you're part of Manfred's staff, download the necessary environment variables using [Doppler](https://www.doppler.com/): 38 | 39 | ```bash 40 | yarn dev:secrets 41 | ``` 42 | 43 | - Run the application locally with a fake sqs event. This uses the `LOCAL_PROFILE_API_TOKEN` environment variable to retrieve the LinkedIn profile (by default dev Manfred user): 44 | 45 | ```bash 46 | yarn dev 47 | ``` 48 | 49 | - If you need to get new API token: 50 | 51 | ```bash 52 | yarn dev:token 53 | ``` 54 | 55 | - Automatically lint the code and apply fixes to linting and formatting errors: 56 | 57 | ```bash 58 | yarn lint 59 | ``` 60 | 61 | - Execute the unit test suite to ensure that everything is working as expected: 62 | 63 | ```bash 64 | yarn test 65 | ``` 66 | 67 | - If you have `localstack` configured, you can send a real message to the queue, simulate its reception, and handle it with: 68 | 69 | ```bash 70 | yarn dev:consumer 71 | ``` 72 | 73 | Make sure you have all necessary environment variables and dependencies set up before running the tasks. 74 | 75 | # References 76 | 77 | - https://learn.microsoft.com/en-us/linkedin/dma/member-data-portability/member-data-portability-member 78 | -------------------------------------------------------------------------------- /assets/diagram.drawio.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/getmanfred/linkedin-api-lambda/59f2bb27924a96ad298c8b6ab60e1c0154cc4c83/assets/diagram.drawio.png -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | linkedin-api-lambda: 3 | container_name: linkedin-api-lambda 4 | image: linkedin-api-lambda:latest 5 | build: 6 | context: . 7 | dockerfile: ./dockerfile 8 | restart: always 9 | ports: 10 | - 9001:8080 11 | networks: 12 | - app_network 13 | 14 | networks: 15 | app_network: 16 | external: true 17 | -------------------------------------------------------------------------------- /dockerfile: -------------------------------------------------------------------------------- 1 | ## 👉 build stage 2 | FROM node:20.17.0-alpine AS builder 3 | 4 | WORKDIR /app 5 | 6 | COPY package*.json /app/ 7 | RUN npm install 8 | 9 | COPY . /app 10 | RUN npm run build 11 | 12 | ## 👉 runner stage 13 | FROM public.ecr.aws/lambda/nodejs:20 AS runner 14 | 15 | WORKDIR /var/task 16 | 17 | COPY --from=builder /app/dist/ ./ 18 | COPY --from=builder /app/node_modules ./node_modules 19 | COPY --from=builder /app/package*.json ./ 20 | 21 | CMD ["index.handler"] -------------------------------------------------------------------------------- /doppler.yaml: -------------------------------------------------------------------------------- 1 | setup: 2 | project: linkedin-api-lambda 3 | config: dev 4 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import { FlatCompat } from '@eslint/eslintrc'; 2 | import js from '@eslint/js'; 3 | import typescriptEslintEslintPlugin from '@typescript-eslint/eslint-plugin'; 4 | import tsParser from '@typescript-eslint/parser'; 5 | import path from 'node:path'; 6 | import { fileURLToPath } from 'node:url'; 7 | 8 | const __filename = fileURLToPath(import.meta.url); 9 | const __dirname = path.dirname(__filename); 10 | const compat = new FlatCompat({ 11 | baseDirectory: __dirname, 12 | recommendedConfig: js.configs.recommended, 13 | allConfig: js.configs.all 14 | }); 15 | 16 | export default [ 17 | ...compat.extends('plugin:@typescript-eslint/recommended', 'plugin:prettier/recommended'), 18 | { 19 | plugins: { 20 | '@typescript-eslint': typescriptEslintEslintPlugin 21 | }, 22 | 23 | languageOptions: { 24 | parser: tsParser, 25 | ecmaVersion: 5, 26 | sourceType: 'module', 27 | 28 | parserOptions: { 29 | project: './tsconfig.json' 30 | } 31 | }, 32 | 33 | rules: { 34 | '@typescript-eslint/array-type': 'error', 35 | '@typescript-eslint/explicit-function-return-type': 'error', 36 | '@typescript-eslint/explicit-member-accessibility': 'error', 37 | '@typescript-eslint/explicit-module-boundary-types': 'error', 38 | '@typescript-eslint/member-ordering': 'error', 39 | '@typescript-eslint/no-explicit-any': 'error', 40 | '@typescript-eslint/no-floating-promises': 'error', 41 | '@typescript-eslint/no-invalid-this': 'error', 42 | '@typescript-eslint/no-redeclare': 'error', 43 | '@typescript-eslint/no-shadow': 'error', 44 | '@typescript-eslint/no-unnecessary-boolean-literal-compare': 'error', 45 | '@typescript-eslint/no-unused-vars': [ 46 | 'error', 47 | { 48 | vars: 'all', 49 | args: 'none', 50 | ignoreRestSiblings: false 51 | } 52 | ], 53 | '@typescript-eslint/prefer-readonly': 'error', 54 | '@typescript-eslint/promise-function-async': 'error', 55 | '@typescript-eslint/no-use-before-define': ['error', { classes: true }], 56 | 57 | 'no-console': 'warn', 58 | 'no-process-env': 'error', 59 | 'no-return-await': 'error' 60 | } 61 | } 62 | ]; 63 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | roots: [''], 3 | setupFilesAfterEnv: ['/jest.setup.js'], 4 | transform: { '^.+\\.ts$': 'ts-jest' }, 5 | moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], 6 | modulePathIgnorePatterns: ['/node_modules', '/dist'], 7 | preset: 'ts-jest', 8 | testEnvironment: 'node', 9 | coverageReporters: ['lcov', 'text-summary', 'clover'], 10 | collectCoverageFrom: [ 11 | 'src/**/*.ts', 12 | '!src/test/**/*', 13 | '!src/**/*spec.ts', 14 | '!src/launch.ts', 15 | '!src/launch-sqs-consumer.ts', 16 | '!src/get-linkedin-token.ts' 17 | ] 18 | }; 19 | -------------------------------------------------------------------------------- /jest.setup.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | require('reflect-metadata'); 4 | 5 | // disable console.log in tests 6 | global.console = { 7 | log: jest.fn(), 8 | info: jest.fn(), 9 | warn: jest.fn(), 10 | error: jest.fn(), 11 | debug: jest.fn() 12 | }; 13 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "linkedin-api-lambda", 3 | "description": "Import Linkedin profile using Linkedin Member Data Portability API For European Union (as Manfred Awesomic CV format)", 4 | "homepage": "https://github.com/getmanfred/linkedin-api-lambda/blob/main/README.md", 5 | "author": "Manfred Team", 6 | "license": "CC-BY-SA-4.0", 7 | "version": "0.0.1", 8 | "main": "dist/index.js", 9 | "scripts": { 10 | "build": "tsc", 11 | "start": "node -r esbuild-register ./src/index.ts", 12 | "lint": "eslint src/**/*.ts* --fix --no-warn-ignored", 13 | "test": "jest --silent --logHeapUsage --coverage", 14 | "dev": "node --env-file=.env -r esbuild-register ./src/launch.ts -e .ts", 15 | "dev:secrets": "doppler setup --no-interactive && doppler secrets download --no-file --format env > .env && touch .env.overrides && cat .env.overrides >> .env", 16 | "dev:token": "node --env-file=.env -r esbuild-register ./src/get-linkedin-token.ts", 17 | "dev:consumer": "node --env-file=.env -r esbuild-register ./src/launch-sqs-consumer.ts", 18 | "audit:critical": "npm audit --audit-level=critical", 19 | "clean": "rm -rf dist node_modules" 20 | }, 21 | "dependencies": { 22 | "@aws-sdk/client-sqs": "3.709.0", 23 | "aws-lambda": "1.0.7", 24 | "axios": "1.7.9", 25 | "class-transformer": "0.5.1", 26 | "class-validator": "0.14.1", 27 | "date-fns": "4.1.0", 28 | "envalid": "8.0.0", 29 | "esbuild": "0.24.0", 30 | "reflect-metadata": "0.2.2", 31 | "uuid": "11.0.3", 32 | "winston": "3.17.0" 33 | }, 34 | "devDependencies": { 35 | "@eslint/eslintrc": "3.2.0", 36 | "@eslint/js": "9.16.0", 37 | "@types/aws-lambda": "8.10.146", 38 | "@types/jest": "29.5.14", 39 | "@types/node": "22.10.1", 40 | "@typescript-eslint/eslint-plugin": "8.18.0", 41 | "@typescript-eslint/parser": "8.18.0", 42 | "better-opn": "3.0.2", 43 | "esbuild-register": "3.6.0", 44 | "eslint": "9.16.0", 45 | "eslint-config-prettier": "9.1.0", 46 | "eslint-plugin-prettier": "5.2.1", 47 | "jest": "29.7.0", 48 | "jest-mock-extended": "3.0.7", 49 | "prettier": "3.4.2", 50 | "prettier-plugin-sort-imports": "1.8.6", 51 | "sqs-consumer": "11.2.0", 52 | "ts-jest": "29.2.5", 53 | "typescript": "5.7.2" 54 | }, 55 | "engines": { 56 | "node": "20.17.0" 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/__snapshots__/index.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Linkedin lambda handler when handling a successful request should return a response with Linkedin Profile as Mac Profile 1`] = ` 4 | LinkedinProfileResponse { 5 | "contextId": "1234", 6 | "importId": "1", 7 | "profile": LinkedinProfileResponseMac { 8 | "$schema": "https://raw.githubusercontent.com/getmanfred/mac/v0.5/schema/schema.json", 9 | "aboutMe": LinkedinProfileResponseMacAboutMe { 10 | "profile": LinkedinProfileResponseMacPerson { 11 | "description": "I like to learn new things every day", 12 | "name": "", 13 | "surnames": "", 14 | "title": "Software Engineer @Manfred", 15 | }, 16 | }, 17 | "experience": LinkedinProfileResponseMacExperience { 18 | "jobs": [ 19 | LinkedinProfileResponseMacJob { 20 | "organization": LinkedinProfileResponseMacPublicEntity { 21 | "name": "Manfred", 22 | }, 23 | "roles": [ 24 | LinkedinProfileResponseMacJobRole { 25 | "challenges": [ 26 | LinkedinProfileResponseMacExperienceJobChallenge { 27 | "description": "Product development implemented in Node, Express, Nest.js, Typescript, Postgres, Kubernetes, AWS: SQS, Lambda, S3...", 28 | }, 29 | ], 30 | "finishDate": "2026-03-01", 31 | "name": "Senior Backend Developer", 32 | "startDate": "2022-03-01", 33 | }, 34 | ], 35 | }, 36 | ], 37 | }, 38 | "knowledge": LinkedinProfileResponseMacKnowledge { 39 | "hardSkills": [ 40 | LinkedinProfileResponseMacSkill { 41 | "skill": LinkedinProfileResponseMacCompetence { 42 | "name": "TypeScript", 43 | "type": "technology", 44 | }, 45 | }, 46 | ], 47 | "studies": [ 48 | LinkedinProfileResponseMacStudy { 49 | "degreeAchieved": true, 50 | "description": "Computer Engineering", 51 | "finishDate": "2012-01-01", 52 | "institution": LinkedinProfileResponseMacPublicEntity { 53 | "name": "University of A Coruna", 54 | }, 55 | "name": "Computer Engineering", 56 | "startDate": "2005-01-01", 57 | "studyType": "officialDegree", 58 | }, 59 | ], 60 | }, 61 | "settings": LinkedinProfileResponseMacSettings { 62 | "language": "EN", 63 | }, 64 | }, 65 | "profileId": 356, 66 | "timeElapsed": 1000, 67 | } 68 | `; 69 | 70 | exports[`Linkedin lambda handler when handling a successful request should return a response with Linkedin Profile as Mac Profile removing duplicates 1`] = ` 71 | LinkedinProfileResponse { 72 | "contextId": "1234", 73 | "importId": "1", 74 | "profile": LinkedinProfileResponseMac { 75 | "$schema": "https://raw.githubusercontent.com/getmanfred/mac/v0.5/schema/schema.json", 76 | "aboutMe": LinkedinProfileResponseMacAboutMe { 77 | "profile": LinkedinProfileResponseMacPerson { 78 | "description": "I like to learn new things every day", 79 | "name": "", 80 | "surnames": "", 81 | "title": "Software Engineer @Manfred", 82 | }, 83 | }, 84 | "experience": LinkedinProfileResponseMacExperience { 85 | "jobs": [ 86 | LinkedinProfileResponseMacJob { 87 | "organization": LinkedinProfileResponseMacPublicEntity { 88 | "name": "Manfred", 89 | }, 90 | "roles": [ 91 | LinkedinProfileResponseMacJobRole { 92 | "challenges": [ 93 | LinkedinProfileResponseMacExperienceJobChallenge { 94 | "description": "Product development implemented in Node, Express, Nest.js, Typescript, Postgres, Kubernetes, AWS: SQS, Lambda, S3...", 95 | }, 96 | ], 97 | "finishDate": "2026-03-01", 98 | "name": "Senior Backend Developer", 99 | "startDate": "2022-03-01", 100 | }, 101 | ], 102 | }, 103 | ], 104 | }, 105 | "knowledge": LinkedinProfileResponseMacKnowledge { 106 | "hardSkills": [ 107 | LinkedinProfileResponseMacSkill { 108 | "skill": LinkedinProfileResponseMacCompetence { 109 | "name": "TypeScript", 110 | "type": "technology", 111 | }, 112 | }, 113 | ], 114 | "studies": [ 115 | LinkedinProfileResponseMacStudy { 116 | "degreeAchieved": true, 117 | "description": "Computer Engineering", 118 | "finishDate": "2012-01-01", 119 | "institution": LinkedinProfileResponseMacPublicEntity { 120 | "name": "University of A Coruna", 121 | }, 122 | "name": "Computer Engineering", 123 | "startDate": "2005-01-01", 124 | "studyType": "officialDegree", 125 | }, 126 | ], 127 | }, 128 | "settings": LinkedinProfileResponseMacSettings { 129 | "language": "EN", 130 | }, 131 | }, 132 | "profileId": 356, 133 | "timeElapsed": 1000, 134 | } 135 | `; 136 | -------------------------------------------------------------------------------- /src/contracts/linkedin-profile.request.ts: -------------------------------------------------------------------------------- 1 | import { IsIn, IsInt, IsNumber, IsString, IsUrl } from 'class-validator'; 2 | 3 | export class LinkedinProfileRequest { 4 | @IsString() 5 | public messageId!: string; 6 | 7 | @IsString() 8 | public importId!: string; 9 | 10 | @IsString() 11 | public contextId!: string; 12 | 13 | @IsIn(['local', 'stage', 'pro']) 14 | public env!: 'local' | 'stage' | 'pro'; 15 | 16 | @IsNumber() 17 | @IsInt() 18 | public profileId!: number; 19 | 20 | // Profile parameters 21 | @IsString() 22 | public linkedinApiToken!: string; 23 | 24 | @IsUrl() 25 | public linkedinProfileUrl!: string; 26 | 27 | @IsInt() 28 | public attempt!: number; 29 | } 30 | -------------------------------------------------------------------------------- /src/contracts/linkedin-profile.response.ts: -------------------------------------------------------------------------------- 1 | import { Type } from 'class-transformer'; 2 | import { 3 | ArrayMinSize, 4 | IsArray, 5 | IsBoolean, 6 | IsDateString, 7 | IsIn, 8 | IsNotEmpty, 9 | IsNumber, 10 | IsObject, 11 | IsOptional, 12 | IsString, 13 | MaxLength, 14 | ValidateNested 15 | } from 'class-validator'; 16 | 17 | export class LinkedinProfileResponseMacSettings { 18 | @IsString() 19 | @IsIn(['EN', 'ES']) 20 | public language!: 'ES' | 'EN'; 21 | } 22 | 23 | export class LinkedinProfileResponseMacPerson { 24 | @IsString() 25 | public name: string = ''; 26 | 27 | @IsString() 28 | public surnames: string = ''; 29 | 30 | @IsString() 31 | @IsNotEmpty() 32 | @MaxLength(255) 33 | public title!: string; 34 | 35 | @IsString() 36 | @IsOptional() 37 | public description?: string; 38 | } 39 | 40 | export class LinkedinProfileResponseMacExperienceJobChallenge { 41 | @IsString() 42 | public description!: string; 43 | } 44 | 45 | export class LinkedinProfileResponseMacJobRole { 46 | @IsString() 47 | @IsNotEmpty() 48 | public name!: string; 49 | 50 | @IsDateString() 51 | public startDate!: string; 52 | 53 | @IsDateString() 54 | @IsOptional() 55 | public finishDate?: string; 56 | 57 | @IsArray() 58 | @IsOptional() 59 | @ValidateNested({ each: true }) 60 | @Type(() => LinkedinProfileResponseMacExperienceJobChallenge) 61 | public challenges?: LinkedinProfileResponseMacExperienceJobChallenge[]; 62 | } 63 | 64 | export class LinkedinProfileResponseMacPublicEntity { 65 | @IsString() 66 | @IsNotEmpty() 67 | public name!: string; 68 | } 69 | export class LinkedinProfileResponseMacJob { 70 | @IsObject() 71 | @ValidateNested() 72 | @Type(() => LinkedinProfileResponseMacPublicEntity) 73 | public organization!: LinkedinProfileResponseMacPublicEntity; 74 | 75 | @IsArray() 76 | @ValidateNested({ each: true }) 77 | @ArrayMinSize(1) 78 | @Type(() => LinkedinProfileResponseMacJobRole) 79 | public roles!: LinkedinProfileResponseMacJobRole[]; 80 | } 81 | export class LinkedinProfileResponseMacExperience { 82 | @IsArray() 83 | @IsOptional() 84 | @ValidateNested({ each: true }) 85 | @Type(() => LinkedinProfileResponseMacJob) 86 | public jobs?: LinkedinProfileResponseMacJob[]; 87 | } 88 | 89 | export class LinkedinProfileResponseMacAboutMe { 90 | @IsObject() 91 | @ValidateNested() 92 | @Type(() => LinkedinProfileResponseMacPerson) 93 | public profile!: LinkedinProfileResponseMacPerson; 94 | 95 | public constructor() { 96 | this.profile = new LinkedinProfileResponseMacPerson(); 97 | } 98 | } 99 | 100 | export class LinkedinProfileResponseMacCompetence { 101 | @IsString() 102 | @IsNotEmpty() 103 | public name!: string; 104 | 105 | @IsString() 106 | @IsIn(['tool', 'technology', 'practice', 'hardware', 'domain']) 107 | public type!: 'tool' | 'technology' | 'practice' | 'hardware' | 'domain'; 108 | } 109 | 110 | export class LinkedinProfileResponseMacSkill { 111 | @IsObject() 112 | @ValidateNested() 113 | @Type(() => LinkedinProfileResponseMacCompetence) 114 | @IsOptional() 115 | public skill?: LinkedinProfileResponseMacCompetence; 116 | } 117 | 118 | export class LinkedinProfileResponseMacStudy { 119 | @IsString() 120 | @IsIn(['officialDegree', 'certification', 'unaccredited', 'selfTraining']) 121 | public studyType!: 'officialDegree' | 'certification' | 'unaccredited' | 'selfTraining'; 122 | 123 | @IsBoolean() 124 | public degreeAchieved!: boolean; 125 | 126 | @IsString() 127 | @IsNotEmpty() 128 | public name!: string; 129 | 130 | @IsString() 131 | @IsOptional() 132 | public description?: string; 133 | 134 | @IsDateString() 135 | public startDate!: string; 136 | 137 | @IsDateString() 138 | @IsOptional() 139 | public finishDate?: string; 140 | 141 | @IsObject() 142 | @ValidateNested() 143 | @Type(() => LinkedinProfileResponseMacPublicEntity) 144 | @IsOptional() 145 | public institution?: LinkedinProfileResponseMacPublicEntity; 146 | } 147 | 148 | export class LinkedinProfileResponseMacKnowledge { 149 | @IsArray() 150 | @IsOptional() 151 | @ValidateNested({ each: true }) 152 | @Type(() => LinkedinProfileResponseMacSkill) 153 | public hardSkills?: LinkedinProfileResponseMacSkill[]; 154 | 155 | @IsArray() 156 | @IsOptional() 157 | @ValidateNested({ each: true }) 158 | @Type(() => LinkedinProfileResponseMacStudy) 159 | public studies?: LinkedinProfileResponseMacStudy[]; 160 | } 161 | 162 | // -- 👉 main classes 163 | 164 | /** 📝 165 | - Reduced version of MAC (only with current imported Linkedin fields) 166 | - See full version in: https://raw.githubusercontent.com/getmanfred/mac/v0.5/schema/schema.json 167 | */ 168 | export class LinkedinProfileResponseMac { 169 | @IsString() 170 | @IsNotEmpty() 171 | public $schema = 'https://raw.githubusercontent.com/getmanfred/mac/v0.5/schema/schema.json'; 172 | 173 | @IsObject() 174 | @ValidateNested() 175 | @Type(() => LinkedinProfileResponseMacSettings) 176 | public settings!: LinkedinProfileResponseMacSettings; 177 | 178 | @IsObject() 179 | @ValidateNested() 180 | @Type(() => LinkedinProfileResponseMacAboutMe) 181 | public aboutMe!: LinkedinProfileResponseMacAboutMe; 182 | 183 | @IsObject() 184 | @IsOptional() 185 | @ValidateNested() 186 | @Type(() => LinkedinProfileResponseMacExperience) 187 | public experience?: LinkedinProfileResponseMacExperience; 188 | 189 | @IsObject() 190 | @IsOptional() 191 | @ValidateNested() 192 | @Type(() => LinkedinProfileResponseMacKnowledge) 193 | public knowledge?: LinkedinProfileResponseMacKnowledge; 194 | 195 | public constructor() { 196 | this.settings = new LinkedinProfileResponseMacSettings(); 197 | this.settings.language = 'EN'; 198 | this.aboutMe = new LinkedinProfileResponseMacAboutMe(); 199 | } 200 | } 201 | 202 | export type LinkedinProfileResponseErrorType = 'unknown' | 'account-locked' | 'timeout' | 'expired' | 'invalid-mac' | 'not-found'; 203 | 204 | export class LinkedinProfileResponse { 205 | @IsString() 206 | public importId!: string; 207 | 208 | @IsString() 209 | public contextId!: string; 210 | 211 | @IsNumber() 212 | public profileId!: number; 213 | 214 | @IsOptional() 215 | @IsNumber() 216 | public timeElapsed?: number; 217 | 218 | @IsOptional() 219 | @IsObject() 220 | @ValidateNested() 221 | @Type(() => LinkedinProfileResponseMac) 222 | public profile?: LinkedinProfileResponseMac; 223 | 224 | @IsOptional() 225 | @IsIn(['unknown', 'expired', 'invalid-mac', 'not-found', 'account-locked', 'timeout']) 226 | public errorType?: LinkedinProfileResponseErrorType; 227 | 228 | @IsOptional() 229 | @IsString() 230 | public errorMessage?: string; 231 | } 232 | -------------------------------------------------------------------------------- /src/domain/errors/invalid-mac.error.ts: -------------------------------------------------------------------------------- 1 | export class InvalidMacError extends Error { 2 | public constructor(message: string) { 3 | super(message); 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /src/domain/errors/max-retries.error.ts: -------------------------------------------------------------------------------- 1 | export class MaxRetriesError extends Error { 2 | public constructor(message: string) { 3 | super(message); 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /src/domain/linkedin-profile.ts: -------------------------------------------------------------------------------- 1 | export class LinkedinProfileProfile { 2 | public 'First Name'?: string; 3 | public 'Last Name'?: string; 4 | public 'Headline'?: string; 5 | public 'Summary'?: string; 6 | } 7 | 8 | export class LinkedinProfileSkill { 9 | public 'Name'?: string; 10 | } 11 | 12 | export class LinkedinProfilePosition { 13 | public 'Title'?: string; 14 | public 'Description'?: string; 15 | public 'Company Name'?: string; 16 | public 'Started On'?: string; 17 | public 'Finished On'?: string; 18 | } 19 | 20 | export class LinkedinProfileEducation { 21 | public 'School Name'?: string; 22 | public 'Degree Name'?: string; 23 | public 'Start Date'?: string; 24 | public 'End Date'?: string; 25 | } 26 | 27 | // -- 👉 main class 28 | export class LinkedinProfile { 29 | public profile: LinkedinProfileProfile; 30 | public skills: LinkedinProfileSkill[]; 31 | public positions: LinkedinProfilePosition[]; 32 | public education: LinkedinProfileEducation[]; 33 | 34 | public constructor() { 35 | this.profile = new LinkedinProfileProfile(); 36 | this.skills = []; 37 | this.positions = []; 38 | this.education = []; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/domain/logger-context.ts: -------------------------------------------------------------------------------- 1 | export type EnvironmentType = 'local' | 'stage' | 'pro'; 2 | 3 | export interface LoggerContext { 4 | messageId: string; 5 | contextId: string; 6 | profileId: number; 7 | env: EnvironmentType; 8 | } 9 | -------------------------------------------------------------------------------- /src/get-linkedin-token.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/ban-ts-comment */ 2 | /* eslint-disable no-process-env */ 3 | /* eslint-disable no-console */ 4 | import axios from 'axios'; 5 | 6 | // @ts-expect-error 7 | import betterOpn from 'better-opn'; 8 | import { createServer } from 'http'; 9 | 10 | /** 11 | * 👉 Script: Retrieves Linkedin API token for local testing (LOCAL_LINKEDIN_API_TOKEN environment variable value) 12 | */ 13 | 14 | const CLIENT_ID = process.env.LOCAL_LINKEDIN_CLIENT_ID!; 15 | const CLIENT_SECRET = process.env.LOCAL_LINKEDIN_CLIENT_SECRET!; 16 | const SCOPE = 'r_dma_portability_3rd_party'; 17 | const REDIRECT_URI = 'http://localhost:3000/callback'; 18 | 19 | const startOAuthFlow = async (): Promise => { 20 | const authUrl = `https://www.linkedin.com/oauth/v2/authorization?response_type=code&client_id=${CLIENT_ID}&redirect_uri=${REDIRECT_URI}&scope=${SCOPE}`; 21 | console.log('👉 Opening LinkedIn OAuth URL...'); 22 | await betterOpn(authUrl); 23 | }; 24 | 25 | // Create a simple server to handle the LinkedIn redirect 26 | createServer(async (req, res) => { 27 | if (req.url?.startsWith('/callback')) { 28 | const urlParams = new URLSearchParams(req.url.split('?')[1]); 29 | const code = urlParams.get('code'); 30 | 31 | console.log('🔑 Authorization Code:', code); 32 | 33 | if (!code) { 34 | res.writeHead(400, { 'Content-Type': 'text/plain' }); 35 | res.end('Authorization code is missing.'); 36 | return; 37 | } 38 | 39 | try { 40 | const tokenUrl = 'https://www.linkedin.com/oauth/v2/accessToken'; 41 | const tokenUrlParams = new URLSearchParams({ 42 | grant_type: 'authorization_code', 43 | code, 44 | redirect_uri: REDIRECT_URI, 45 | client_id: CLIENT_ID!, 46 | client_secret: CLIENT_SECRET! 47 | }).toString(); 48 | 49 | const response = await axios.post(tokenUrl, tokenUrlParams, { headers: { 'Content-Type': 'application/x-www-form-urlencoded' } }); 50 | const accessToken = response.data.access_token; 51 | 52 | console.log('✅ Access Token:', accessToken); 53 | 54 | res.writeHead(200, { 'Content-Type': 'text/plain' }); 55 | res.end(`Access Token: ${accessToken}\n\nYou can now close this window.`); 56 | } catch (error: unknown) { 57 | console.error('❌ Error exchanging code for token:', error); 58 | res.writeHead(500, { 'Content-Type': 'text/plain' }); 59 | res.end('Failed to get access token.'); 60 | } 61 | } 62 | }).listen(3000, () => { 63 | console.log('🚀 Server running at http://localhost:3000'); 64 | startOAuthFlow().catch((error) => { 65 | console.error('❌ Error starting OAuth flow:', error); 66 | }); 67 | }); 68 | -------------------------------------------------------------------------------- /src/index.spec.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-process-env */ 2 | import { Context } from 'aws-lambda'; 3 | import { mock, mockReset } from 'jest-mock-extended'; 4 | import { InvalidMacError } from './domain/errors/invalid-mac.error'; 5 | import { MaxRetriesError } from './domain/errors/max-retries.error'; 6 | import { handler } from './index'; 7 | import { LinkedinProfileService } from './services/linkedin-profile.service'; 8 | import { QueueClient } from './services/queue.client'; 9 | import { 10 | createMockedLinkedinProfile, 11 | createMockedLinkedinProfileEmpty, 12 | createMockedLinkedinProfileRequest 13 | } from './test/mocks/linkedin-profile.mocks'; 14 | import { createMockedSqsSEvent } from './test/mocks/sqs.mocks'; 15 | 16 | const linkedinProfileService = mock(); 17 | 18 | jest.mock('./services/linkedin-profile.service', () => ({ 19 | LinkedinProfileService: jest.fn(() => linkedinProfileService) 20 | })); 21 | 22 | jest.mock('./services/queue.client', () => ({ 23 | QueueClient: { 24 | sendToResultQueue: jest.fn().mockResolvedValue(undefined), 25 | resendMessage: jest.fn().mockResolvedValue(undefined), 26 | removeMessage: jest.fn().mockResolvedValue(undefined) 27 | } 28 | })); 29 | 30 | describe('Linkedin lambda handler', () => { 31 | beforeEach(() => { 32 | mockReset(linkedinProfileService); 33 | jest.clearAllMocks(); 34 | 35 | process.env.AWS_QUEUE_URL = 'queue-url'; 36 | process.env.AWS_RESULT_QUEUE_URL = 'result-queue-url'; 37 | }); 38 | 39 | describe('when handling a successful request', () => { 40 | it('should return a response with Linkedin Profile as Mac Profile', async () => { 41 | const event = createMockedSqsSEvent(); 42 | const expectedLinkedinProfile = createMockedLinkedinProfile(); 43 | 44 | linkedinProfileService.getLinkedinProfile.calledWith('fake-token').mockResolvedValue({ 45 | linkedinProfile: expectedLinkedinProfile, 46 | isEmptyProfile: false, 47 | timeElapsed: 1000 48 | }); 49 | 50 | const response = await handler(event, {} as Context, () => {}); 51 | 52 | expect(response).toMatchSnapshot(); 53 | }); 54 | 55 | it('should return a response with Linkedin Profile as Mac Profile removing duplicates', async () => { 56 | const event = createMockedSqsSEvent(); 57 | const expectedLinkedinProfile = createMockedLinkedinProfile(); 58 | expectedLinkedinProfile.education = expectedLinkedinProfile.education.concat(expectedLinkedinProfile.education); 59 | expectedLinkedinProfile.positions = expectedLinkedinProfile.positions.concat(expectedLinkedinProfile.positions); 60 | 61 | linkedinProfileService.getLinkedinProfile.calledWith('fake-token').mockResolvedValue({ 62 | linkedinProfile: expectedLinkedinProfile, 63 | isEmptyProfile: false, 64 | timeElapsed: 1000 65 | }); 66 | 67 | const response = await handler(event, {} as Context, () => {}); 68 | 69 | expect(response).toMatchSnapshot(); 70 | }); 71 | 72 | it('should send response to sqs result queue ', async () => { 73 | const event = createMockedSqsSEvent(); 74 | const expectedLinkedinProfile = createMockedLinkedinProfile(); 75 | 76 | linkedinProfileService.getLinkedinProfile.calledWith('fake-token').mockResolvedValue({ 77 | linkedinProfile: expectedLinkedinProfile, 78 | isEmptyProfile: false, 79 | timeElapsed: 1000 80 | }); 81 | 82 | const response = await handler(event, {} as Context, () => {}); 83 | 84 | expect(QueueClient.sendToResultQueue).toHaveBeenCalledWith(response, expect.anything()); 85 | }); 86 | }); 87 | 88 | describe('when handling a failed request', () => { 89 | it('should throw an error if number of messages is not 1', async () => { 90 | const event = createMockedSqsSEvent(); 91 | event.Records = [{ ...event.Records[0] }, { ...event.Records[0] }]; 92 | 93 | await expect(handler(event, {} as Context, () => {})).rejects.toThrow( 94 | new Error('[LinkedinProfileRequestMapper] Batch size must be configured to 1') 95 | ); 96 | }); 97 | 98 | it('should throw an error if message is not a valid LinkedinProfileRequest', async () => { 99 | const request = createMockedLinkedinProfileRequest(); 100 | request.linkedinApiToken = undefined as unknown as string; // missing required field 101 | const event = createMockedSqsSEvent(request); 102 | const expectedErrorString = 103 | '[LinkedinProfileRequestMapper] Validation failed: ["property: linkedinApiToken errors: linkedinApiToken must be a string"]'; 104 | 105 | await expect(handler(event, {} as Context, () => {})).rejects.toThrow(new Error(expectedErrorString)); 106 | }); 107 | }); 108 | 109 | describe('when handling an empty response', () => { 110 | it('should resend message to sqs queue if empty response and no reached max retries ', async () => { 111 | const event = createMockedSqsSEvent(); 112 | const expectedLinkedinProfile = createMockedLinkedinProfileEmpty(); 113 | 114 | linkedinProfileService.getLinkedinProfile.calledWith('fake-token').mockResolvedValue({ 115 | linkedinProfile: expectedLinkedinProfile, 116 | isEmptyProfile: true, 117 | timeElapsed: 1000 118 | }); 119 | 120 | await handler(event, {} as Context, () => {}); 121 | expect(QueueClient.resendMessage).toHaveBeenCalled(); 122 | }); 123 | 124 | it('should throw an error if max retries reached', async () => { 125 | const request = createMockedLinkedinProfileRequest({ attempt: 3 }); 126 | const event = createMockedSqsSEvent(request); 127 | const expectedLinkedinProfile = createMockedLinkedinProfileEmpty(); 128 | 129 | linkedinProfileService.getLinkedinProfile.calledWith('fake-token').mockResolvedValue({ 130 | linkedinProfile: expectedLinkedinProfile, 131 | isEmptyProfile: true, 132 | timeElapsed: 1000 133 | }); 134 | 135 | await expect(handler(event, {} as Context, () => {})).rejects.toThrow(MaxRetriesError); 136 | }); 137 | }); 138 | 139 | describe('when handling a failed response', () => { 140 | it('should throw an error if response is not a valid LinkedinProfileResponse', async () => { 141 | const event = createMockedSqsSEvent(); 142 | const expectedLinkedinProfile = createMockedLinkedinProfileEmpty(); 143 | const expectedErrorString = 144 | '[LinkedinProfileResponseMapper] MAC Validation failed: ["property: profile.aboutMe.profile.title errors: title should not be empty"]'; 145 | 146 | linkedinProfileService.getLinkedinProfile.calledWith('fake-token').mockResolvedValue({ 147 | linkedinProfile: expectedLinkedinProfile, 148 | isEmptyProfile: false, 149 | timeElapsed: 1000 150 | }); 151 | 152 | await expect(handler(event, {} as Context, () => {})).rejects.toThrow(new InvalidMacError(expectedErrorString)); 153 | }); 154 | 155 | it('should send response to sqs result queue ', async () => { 156 | const event = createMockedSqsSEvent(); 157 | const expectedLinkedinProfile = createMockedLinkedinProfileEmpty(); 158 | const expectedErrorString = 159 | '[LinkedinProfileResponseMapper] MAC Validation failed: ["property: profile.aboutMe.profile.title errors: title should not be empty"]'; 160 | 161 | linkedinProfileService.getLinkedinProfile.calledWith('fake-token').mockResolvedValue({ 162 | linkedinProfile: expectedLinkedinProfile, 163 | isEmptyProfile: false, 164 | timeElapsed: 1000 165 | }); 166 | 167 | await expect(handler(event, {} as Context, () => {})).rejects.toThrow(new Error(expectedErrorString)); 168 | expect(QueueClient.sendToResultQueue).toHaveBeenCalled(); 169 | }); 170 | 171 | it('should handle unknown errors and send to result queue', async () => { 172 | const event = createMockedSqsSEvent(); 173 | 174 | jest.spyOn(linkedinProfileService, 'getLinkedinProfile').mockRejectedValue(new Error()); 175 | 176 | await expect(handler(event, {} as Context, () => {})).rejects.toThrow(); 177 | 178 | expect(QueueClient.sendToResultQueue).toHaveBeenCalledWith( 179 | expect.objectContaining({ 180 | errorType: 'unknown', 181 | errorMessage: 'unknown error' 182 | }), 183 | expect.anything() 184 | ); 185 | }); 186 | }); 187 | }); 188 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { Handler, SQSEvent } from 'aws-lambda'; 2 | import 'reflect-metadata'; 3 | import { LinkedinProfileResponse } from './contracts/linkedin-profile.response'; 4 | import { InvalidMacError } from './domain/errors/invalid-mac.error'; 5 | import { MaxRetriesError } from './domain/errors/max-retries.error'; 6 | import { LinkedinProfileRequestMapper } from './mappers/linkedin-profile.request.mapper'; 7 | import { LinkedinProfileResponseMapper } from './mappers/linkedin-profile.response.mapper'; 8 | import { LinkedinProfileService } from './services/linkedin-profile.service'; 9 | import { QueueClient } from './services/queue.client'; 10 | import { Environment } from './util/environment'; 11 | import { logger } from './util/logger'; 12 | 13 | export const handler: Handler = async (event: SQSEvent): Promise => { 14 | const request = LinkedinProfileRequestMapper.toDomain(event); 15 | const env = Environment.setupEnvironment(request); 16 | const receiptHandle = event.Records[0].receiptHandle; 17 | 18 | try { 19 | logger.info(`⌛️ [handler] Starting Linkedin profile request for linkedinProfileUrl: ${request.linkedinProfileUrl}`); 20 | 21 | const { linkedinProfile, isEmptyProfile, timeElapsed } = await new LinkedinProfileService().getLinkedinProfile(request.linkedinApiToken); 22 | 23 | if (!isEmptyProfile && linkedinProfile) { 24 | const linkedinProfileResponse = LinkedinProfileResponseMapper.toResponse(linkedinProfile, request, timeElapsed); 25 | logger.info(`✅ [handler] Linkedin profile response with MAC: ${JSON.stringify(linkedinProfileResponse.profile)}`); 26 | await QueueClient.sendToResultQueue(linkedinProfileResponse, env); 27 | return linkedinProfileResponse; 28 | } 29 | 30 | logger.warn(`👻 [handler] Linkedin profile is not synced for linkedinProfileUrl: ${request.linkedinProfileUrl}`); 31 | if (request.attempt >= env.MAX_RETRIES) throw new MaxRetriesError(`Max attempts reached for Linkedin profile request: ${env.MAX_RETRIES}`); 32 | await QueueClient.resendMessage(request, env); 33 | 34 | return undefined; 35 | } catch (error: unknown) { 36 | const errorType = error instanceof MaxRetriesError ? 'expired' : error instanceof InvalidMacError ? 'invalid-mac' : 'unknown'; 37 | const errorMessage = (error as Error)?.message || 'unknown error'; 38 | 39 | logger.error(`❌ [handler] Error processing Linkedin profile request`, { error, errorType, errorMessage, event }); 40 | const result = LinkedinProfileResponseMapper.toErrorResponse(errorType, errorMessage, request); 41 | await QueueClient.sendToResultQueue(result, env); 42 | await QueueClient.removeMessage(receiptHandle, env); 43 | throw error; 44 | } 45 | }; 46 | -------------------------------------------------------------------------------- /src/launch-sqs-consumer.ts: -------------------------------------------------------------------------------- 1 | import { Message, SQSClient } from '@aws-sdk/client-sqs'; 2 | import { Context, SQSEvent, SQSRecord } from 'aws-lambda'; 3 | import { Consumer } from 'sqs-consumer'; 4 | 5 | import { handler } from './index'; 6 | import { logger } from './util/logger'; 7 | 8 | /** 9 | * 👉 Script: Test the SQS consumer locally using LocalStack queues 10 | */ 11 | const sqsClient = new SQSClient({ region: 'eu-west-1', endpoint: 'http://localhost:4566' }); 12 | 13 | const queueUrl = 'http://localhost:4566/000000000000/linkedin-api-import-profile-local.fifo'; 14 | 15 | const consumer = Consumer.create({ 16 | queueUrl, 17 | alwaysAcknowledge: true, 18 | handleMessage: async (message: Message) => { 19 | logger.info(`👉 Message received from queue with mesageId: ${message.MessageId}:`, message); 20 | 21 | try { 22 | const sqsRecord = { 23 | messageId: message.MessageId!, 24 | receiptHandle: message.ReceiptHandle!, 25 | body: message.Body!, 26 | attributes: message.Attributes || {}, 27 | messageAttributes: message.MessageAttributes || {}, 28 | md5OfBody: message.MD5OfBody || '', 29 | eventSource: 'aws:sqs', 30 | eventSourceARN: `arn:aws:sqs:eu-west-1:000000000000:linkedin-api-import-profile-local.fifo`, 31 | awsRegion: 'eu-west-1' 32 | } as unknown as SQSRecord; 33 | 34 | const event = { Records: [sqsRecord] } as SQSEvent; 35 | 36 | const response = await handler(event, {} as Context, () => {}); 37 | 38 | logger.info(`🏁 Message processed successfully with messageId: ${message.MessageId}`); 39 | return response; 40 | } catch (error) { 41 | logger.error('❌ Error handling message:', error); 42 | throw error; 43 | } 44 | }, 45 | sqs: sqsClient 46 | }); 47 | 48 | consumer.on('error', (err: Error) => { 49 | logger.error('❌ Consumer error:', err.message); 50 | }); 51 | 52 | consumer.on('processing_error', (err: Error) => { 53 | logger.error('❌ Processing error:', err.message); 54 | }); 55 | 56 | consumer.on('empty', () => { 57 | logger.info('🥱 Waiting for messages...'); 58 | }); 59 | 60 | logger.info(`🔄 Starting SQS consumer for ${queueUrl}...`); 61 | consumer.start(); 62 | -------------------------------------------------------------------------------- /src/launch.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-process-env */ 2 | import { Context } from 'aws-lambda'; 3 | import { handler } from './index'; 4 | import { createMockedLinkedinProfileRequest } from './test/mocks/linkedin-profile.mocks'; 5 | import { createMockedSqsSEvent } from './test/mocks/sqs.mocks'; 6 | import { logger } from './util/logger'; 7 | 8 | /** 9 | * 👉 Script: Fake manual execution without AWS: For local debug 10 | */ 11 | void (async (): Promise => { 12 | const linkedinApiToken = process.env['LOCAL_LINKEDIN_API_TOKEN']; 13 | const request = createMockedLinkedinProfileRequest({ linkedinApiToken }); 14 | const fakeEvent = createMockedSqsSEvent(request); 15 | 16 | logger.info('👉 [launch.ts] Debugging lambda handler ...'); 17 | await handler(fakeEvent, {} as Context, () => { 18 | logger.info('✅ [launch.ts] Executed handler'); 19 | }); 20 | })(); 21 | -------------------------------------------------------------------------------- /src/mappers/linkedin-profile.request.mapper.ts: -------------------------------------------------------------------------------- 1 | import { SQSEvent, SQSRecord } from 'aws-lambda'; 2 | import { validateSync } from 'class-validator'; 3 | import { LinkedinProfileRequest } from '../contracts/linkedin-profile.request'; 4 | import { ValidationUtilities } from '../util/validation'; 5 | 6 | export class LinkedinProfileRequestMapper { 7 | public static toDomain(event: SQSEvent): LinkedinProfileRequest { 8 | const message = this.getMessage(event); 9 | const messageBody = JSON.parse(message.body); 10 | 11 | const request = new LinkedinProfileRequest(); 12 | request.messageId = message.messageId; 13 | request.importId = messageBody.importId; 14 | request.contextId = messageBody.contextId; 15 | request.env = messageBody.env; 16 | request.profileId = +messageBody.profileId; 17 | request.linkedinApiToken = messageBody.linkedinApiToken; 18 | request.linkedinProfileUrl = messageBody.linkedinProfileUrl; 19 | request.attempt = messageBody.attempt ? +messageBody.attempt : 1; 20 | 21 | this.validate(request); 22 | 23 | return request; 24 | } 25 | 26 | private static getMessage(event: SQSEvent): SQSRecord { 27 | if (event.Records.length !== 1) throw Error(`[LinkedinProfileRequestMapper] Batch size must be configured to 1`); 28 | return event.Records[0]; 29 | } 30 | 31 | private static validate(request: LinkedinProfileRequest): void { 32 | const errors = validateSync(request); 33 | if (errors.length > 0) { 34 | const formattedErrors = ValidationUtilities.formatErrors(errors); 35 | throw new Error(`[LinkedinProfileRequestMapper] Validation failed: ${JSON.stringify(formattedErrors)}`); 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/mappers/linkedin-profile.response.mapper.ts: -------------------------------------------------------------------------------- 1 | import { validateSync } from 'class-validator'; 2 | import { LinkedinProfileRequest } from '../contracts/linkedin-profile.request'; 3 | import { 4 | LinkedinProfileResponse, 5 | LinkedinProfileResponseMac, 6 | LinkedinProfileResponseMacCompetence, 7 | LinkedinProfileResponseMacExperience, 8 | LinkedinProfileResponseMacExperienceJobChallenge, 9 | LinkedinProfileResponseMacJob, 10 | LinkedinProfileResponseMacJobRole, 11 | LinkedinProfileResponseMacKnowledge, 12 | LinkedinProfileResponseMacPerson, 13 | LinkedinProfileResponseMacPublicEntity, 14 | LinkedinProfileResponseMacSkill, 15 | LinkedinProfileResponseMacStudy 16 | } from '../contracts/linkedin-profile.response'; 17 | import { InvalidMacError } from '../domain/errors/invalid-mac.error'; 18 | import { 19 | LinkedinProfile, 20 | LinkedinProfileEducation, 21 | LinkedinProfilePosition, 22 | LinkedinProfileProfile, 23 | LinkedinProfileSkill 24 | } from '../domain/linkedin-profile'; 25 | import { DateUtilities } from '../util/date'; 26 | import { logger } from '../util/logger'; 27 | import { ValidationUtilities } from '../util/validation'; 28 | 29 | export class LinkedinProfileResponseMapper { 30 | public static toResponse(linkedinProfile: LinkedinProfile, request: LinkedinProfileRequest, timeElapsed: number): LinkedinProfileResponse { 31 | const response = new LinkedinProfileResponse(); 32 | 33 | response.importId = request.importId; 34 | response.contextId = request.contextId; 35 | response.profileId = request.profileId; 36 | response.timeElapsed = timeElapsed; 37 | 38 | response.profile = this.toMac(linkedinProfile); 39 | 40 | this.validate(response); 41 | 42 | return response; 43 | } 44 | 45 | public static toErrorResponse( 46 | errorType: LinkedinProfileResponse['errorType'], 47 | errorMessage: string, 48 | request: LinkedinProfileRequest 49 | ): LinkedinProfileResponse { 50 | const response = new LinkedinProfileResponse(); 51 | 52 | response.importId = request.importId; 53 | response.contextId = request.contextId; 54 | response.profileId = request.profileId; 55 | 56 | response.errorType = errorType; 57 | response.errorMessage = errorMessage; 58 | 59 | this.validate(response); 60 | 61 | return response; 62 | } 63 | 64 | // 🔓 -- Private methods -- 65 | 66 | private static toMac(linkedinProfile: LinkedinProfile): LinkedinProfileResponseMac { 67 | const mac = new LinkedinProfileResponseMac(); 68 | mac.aboutMe.profile = this.toProfile(linkedinProfile.profile); 69 | mac.experience = this.toExperience(linkedinProfile.positions); 70 | mac.knowledge = this.toKnowledge(linkedinProfile.education, linkedinProfile.skills); 71 | return mac; 72 | } 73 | 74 | private static toProfile(profileData: LinkedinProfileProfile): LinkedinProfileResponseMacPerson { 75 | const profile = new LinkedinProfileResponseMacPerson(); 76 | profile.title = profileData.Headline || ''; 77 | profile.description = profileData.Summary || undefined; 78 | return profile; 79 | } 80 | 81 | private static toExperience(positions: LinkedinProfilePosition[]): LinkedinProfileResponseMacExperience { 82 | const experience = new LinkedinProfileResponseMacExperience(); 83 | experience.jobs = []; 84 | 85 | for (const position of positions) { 86 | const job = this.toPosition(position); 87 | 88 | const isDuplicate = experience.jobs.some( 89 | (existingJob) => 90 | existingJob.organization.name === job.organization.name && 91 | existingJob.roles[0].name === job.roles[0].name && 92 | existingJob.roles[0].startDate === job.roles[0].startDate && 93 | existingJob.roles[0].finishDate === job.roles[0].finishDate 94 | ); 95 | 96 | if (!isDuplicate) experience.jobs.push(job); 97 | } 98 | return experience; 99 | } 100 | 101 | private static toKnowledge(education: LinkedinProfileEducation[], skills: LinkedinProfileSkill[]): LinkedinProfileResponseMacKnowledge { 102 | const knowledge = new LinkedinProfileResponseMacKnowledge(); 103 | 104 | knowledge.hardSkills = skills.map((skillItem) => { 105 | const skill = new LinkedinProfileResponseMacSkill(); 106 | const competence = new LinkedinProfileResponseMacCompetence(); 107 | competence.name = skillItem.Name || ''; 108 | competence.type = 'technology'; 109 | skill.skill = competence; 110 | return skill; 111 | }); 112 | 113 | knowledge.studies = []; 114 | 115 | for (const educationItem of education) { 116 | const study = this.toStudy(educationItem); 117 | const isDuplicate = knowledge.studies.some( 118 | (existingStudy) => 119 | existingStudy.name === study.name && 120 | existingStudy.startDate === study.startDate && 121 | existingStudy.finishDate === study.finishDate && 122 | existingStudy.degreeAchieved === study.degreeAchieved 123 | ); 124 | 125 | if (!isDuplicate) knowledge.studies.push(study); 126 | } 127 | return knowledge; 128 | } 129 | 130 | private static validate(response: LinkedinProfileResponse): void { 131 | const errors = validateSync(response); 132 | if (errors.length > 0) { 133 | logger.error(`[LinkedinProfileResponseMapper] MAC Validation failed: ${JSON.stringify(errors)}`, { errors, response }); 134 | const formattedErrors = ValidationUtilities.formatErrors(errors); 135 | throw new InvalidMacError(`[LinkedinProfileResponseMapper] MAC Validation failed: ${JSON.stringify(formattedErrors)}`); 136 | } 137 | } 138 | 139 | private static toPosition(position: LinkedinProfilePosition): LinkedinProfileResponseMacJob { 140 | const job = new LinkedinProfileResponseMacJob(); 141 | const organization = new LinkedinProfileResponseMacPublicEntity(); 142 | organization.name = position['Company Name'] || ''; 143 | job.organization = organization; 144 | 145 | const role = new LinkedinProfileResponseMacJobRole(); 146 | role.name = position.Title || ''; 147 | role.startDate = DateUtilities.toIsoDate(position['Started On']); 148 | role.finishDate = position['Finished On'] ? DateUtilities.toIsoDate(position['Finished On']) : undefined; 149 | 150 | const challenge = new LinkedinProfileResponseMacExperienceJobChallenge(); 151 | challenge.description = position.Description || ''; 152 | role.challenges = [challenge]; 153 | 154 | job.roles = [role]; 155 | 156 | return job; 157 | } 158 | 159 | private static toStudy(educationItem: LinkedinProfileEducation): LinkedinProfileResponseMacStudy { 160 | const study = new LinkedinProfileResponseMacStudy(); 161 | study.studyType = 'officialDegree'; 162 | study.name = educationItem['Degree Name'] || educationItem['School Name'] || ''; 163 | study.startDate = DateUtilities.toIsoDate(educationItem['Start Date']); 164 | study.finishDate = educationItem['End Date'] ? DateUtilities.toIsoDate(educationItem['End Date']) : undefined; 165 | study.degreeAchieved = !!educationItem['End Date']; 166 | study.description = educationItem['Degree Name'] || educationItem['School Name']; 167 | if (educationItem['School Name']) { 168 | const institution = new LinkedinProfileResponseMacPublicEntity(); 169 | institution.name = educationItem['School Name']; 170 | study.institution = institution; 171 | } 172 | return study; 173 | } 174 | } 175 | -------------------------------------------------------------------------------- /src/services/linkedin-api.client.spec.ts: -------------------------------------------------------------------------------- 1 | import axios, { AxiosError, AxiosResponse } from 'axios'; 2 | import { createMockedLinkedinProfile } from '../test/mocks/linkedin-profile.mocks'; 3 | import { LinkedinAPIClient } from './linkedin-api.client'; 4 | 5 | const client = new LinkedinAPIClient(); 6 | 7 | jest.mock('axios', () => { 8 | const originalAxios = jest.requireActual('axios'); 9 | return { 10 | ...originalAxios, 11 | get: jest.fn() 12 | }; 13 | }); 14 | 15 | describe('A linkedin api client', () => { 16 | beforeEach(() => { 17 | jest.clearAllMocks(); 18 | }); 19 | 20 | it('should get a domain array linkedin profile data using linkedin api', async () => { 21 | const expectedEducation = createMockedLinkedinProfile().education; 22 | 23 | const url = `https://api.linkedin.com/rest/memberSnapshotData?q=criteria&domain=EDUCATION`; 24 | const headers = { 25 | Authorization: `Bearer fake_token`, 26 | 'LinkedIn-Version': '202312' 27 | }; 28 | 29 | (axios.get as jest.Mock).mockImplementation(async (calledUrl, calledOptions) => { 30 | if (calledUrl === url && JSON.stringify(calledOptions.headers) === JSON.stringify(headers)) { 31 | return Promise.resolve({ 32 | data: { elements: [{ snapshotData: expectedEducation }] } 33 | }); 34 | } 35 | return undefined; 36 | }); 37 | 38 | const education = await client.fetchProfileDomainData('fake_token', 'EDUCATION', 'ARRAY'); 39 | 40 | expect(education).toEqual(expectedEducation); 41 | }); 42 | 43 | it('should get a domain object linkedin profile data using linkedin api', async () => { 44 | const expectedProfile = createMockedLinkedinProfile().profile; 45 | 46 | const url = `https://api.linkedin.com/rest/memberSnapshotData?q=criteria&domain=PROFILE`; 47 | const headers = { 48 | Authorization: `Bearer fake_token`, 49 | 'LinkedIn-Version': '202312' 50 | }; 51 | 52 | (axios.get as jest.Mock).mockImplementation(async (calledUrl, calledOptions) => { 53 | if (calledUrl === url && JSON.stringify(calledOptions.headers) === JSON.stringify(headers)) { 54 | return Promise.resolve({ 55 | data: { elements: [{ snapshotData: [expectedProfile] }] } 56 | }); 57 | } 58 | return undefined; 59 | }); 60 | 61 | const education = await client.fetchProfileDomainData('fake_token', 'PROFILE', 'OBJECT'); 62 | 63 | expect(education).toEqual(expectedProfile); 64 | }); 65 | 66 | describe('when handling linkedin api errors', () => { 67 | it('should handle error when fetching linkedin profile data', async () => { 68 | const url = `https://api.linkedin.com/rest/memberSnapshotData?q=criteria&domain=PROFILE`; 69 | const headers = { 70 | Authorization: `Bearer fake_token`, 71 | 'LinkedIn-Version': '202312' 72 | }; 73 | 74 | (axios.get as jest.Mock).mockImplementation(async (calledUrl, calledOptions) => { 75 | if (calledUrl === url && JSON.stringify(calledOptions.headers) === JSON.stringify(headers)) { 76 | return Promise.reject({ response: { data: { message: 'Error message' } } }); 77 | } 78 | return undefined; 79 | }); 80 | 81 | await expect(client.fetchProfileDomainData('fake_token', 'PROFILE', 'OBJECT')).rejects.toThrow('Error fetching PROFILE profile data'); 82 | }); 83 | 84 | it('should return an empty array for 404 responses when responseType is ARRAY', async () => { 85 | const mockResponse: AxiosResponse = { 86 | status: 404, 87 | data: null, 88 | headers: {}, 89 | config: { headers: new axios.AxiosHeaders() }, 90 | statusText: 'Not Found' 91 | }; 92 | 93 | const mockError = new AxiosError('Not Found', 'ERR_BAD_REQUEST', undefined, null, mockResponse); 94 | Object.setPrototypeOf(mockError, AxiosError.prototype); 95 | 96 | (axios.get as jest.Mock).mockRejectedValueOnce(mockError); 97 | 98 | const result = await client.fetchProfileDomainData('fake_token', 'SKILLS', 'ARRAY'); 99 | 100 | expect(result).toEqual([]); 101 | }); 102 | it('should return an empty object for 404 responses when responseType is OBJECT', async () => { 103 | const mockResponse: AxiosResponse = { 104 | status: 404, 105 | statusText: 'Not Found', 106 | headers: {}, 107 | config: { headers: new axios.AxiosHeaders() }, 108 | data: null 109 | }; 110 | 111 | const mockError = new AxiosError('Request failed with status code 404', 'ERR_BAD_REQUEST', undefined, null, mockResponse); 112 | 113 | Object.setPrototypeOf(mockError, AxiosError.prototype); 114 | 115 | (axios.get as jest.Mock).mockRejectedValueOnce(mockError); 116 | 117 | const result = await client.fetchProfileDomainData('fake_token', 'PROFILE', 'OBJECT'); 118 | 119 | expect(result).toEqual({}); 120 | }); 121 | 122 | it('should throw an error for non-404 HTTP errors', async () => { 123 | (axios.get as jest.Mock).mockRejectedValueOnce({ 124 | response: { status: 500, data: { error: 'Internal Server Error' } }, 125 | stack: 'Mocked stack trace' 126 | }); 127 | 128 | await expect(client.fetchProfileDomainData('fake_token', 'PROFILE', 'OBJECT')).rejects.toThrow('Error fetching PROFILE profile data'); 129 | }); 130 | 131 | it('should throw an error for unexpected response structure', async () => { 132 | (axios.get as jest.Mock).mockResolvedValueOnce({ 133 | data: { unexpectedKey: 'unexpectedValue' } 134 | }); 135 | 136 | await expect(client.fetchProfileDomainData('fake_token', 'PROFILE', 'OBJECT')).rejects.toThrow('Error fetching PROFILE profile data'); 137 | }); 138 | }); 139 | }); 140 | -------------------------------------------------------------------------------- /src/services/linkedin-api.client.ts: -------------------------------------------------------------------------------- 1 | import axios, { AxiosError } from 'axios'; 2 | import { logger } from '../util/logger'; 3 | 4 | export type LinkedinDomain = 'PROFILE' | 'SKILLS' | 'POSITIONS' | 'EDUCATION'; 5 | export type LinkedinDomainResponseType = 'OBJECT' | 'ARRAY'; 6 | 7 | export class LinkedinAPIClient { 8 | public constructor() {} 9 | 10 | public async fetchProfileDomainData(token: string, domain: LinkedinDomain, responseType: LinkedinDomainResponseType): Promise { 11 | const url = `https://api.linkedin.com/rest/memberSnapshotData?q=criteria&domain=${domain}`; 12 | const isArrayData = responseType === 'ARRAY'; 13 | 14 | logger.debug(`🔍 [LinkedinAPIClient] Fetching ${domain} domain profile data ...`); 15 | try { 16 | const headers = { 17 | Authorization: `Bearer ${token}`, 18 | 'LinkedIn-Version': '202312' 19 | }; 20 | 21 | const response = await axios.get(url, { headers }); 22 | 23 | const data = isArrayData ? response.data.elements[0].snapshotData : response.data.elements[0].snapshotData[0]; 24 | return data; 25 | } catch (error: unknown) { 26 | if (error instanceof AxiosError && error.response?.status === 404) return (isArrayData ? [] : {}) as A; 27 | if (error instanceof AxiosError) { 28 | const responseData = JSON.stringify(error.response?.data); 29 | logger.error(`🚨 [LinkedinAPIClient] Error fetching ${domain} profile data: ${responseData}`, error.stack); 30 | } 31 | throw new Error(`Error fetching ${domain} profile data`); 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/services/linkedin-profile.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { mock, mockReset } from 'jest-mock-extended'; 2 | import { createMockedLinkedinProfile } from '../test/mocks/linkedin-profile.mocks'; 3 | import { LinkedinAPIClient } from './linkedin-api.client'; 4 | import { LinkedinProfileService } from './linkedin-profile.service'; 5 | 6 | const client = mock(); 7 | const service = new LinkedinProfileService(); 8 | 9 | jest.mock('./linkedin-api.client', () => ({ 10 | LinkedinAPIClient: jest.fn(() => client) 11 | })); 12 | 13 | describe('A linkedin profile service', () => { 14 | beforeEach(() => { 15 | jest.clearAllMocks(); 16 | mockReset(client); 17 | }); 18 | 19 | it('should get linkedin profile using client', async () => { 20 | const expectedProfile = createMockedLinkedinProfile(); 21 | 22 | client.fetchProfileDomainData.calledWith('fake_token', 'PROFILE', 'OBJECT').mockResolvedValue(expectedProfile.profile); 23 | client.fetchProfileDomainData.calledWith('fake_token', 'SKILLS', 'ARRAY').mockResolvedValue(expectedProfile.skills); 24 | client.fetchProfileDomainData.calledWith('fake_token', 'POSITIONS', 'ARRAY').mockResolvedValue(expectedProfile.positions); 25 | client.fetchProfileDomainData.calledWith('fake_token', 'EDUCATION', 'ARRAY').mockResolvedValue(expectedProfile.education); 26 | 27 | const { linkedinProfile, isEmptyProfile } = await service.getLinkedinProfile('fake_token'); 28 | expect(linkedinProfile).toEqual(expectedProfile); 29 | expect(isEmptyProfile).toBe(false); 30 | }); 31 | 32 | it('should return empty profile flag', async () => { 33 | client.fetchProfileDomainData.calledWith('fake_token', 'PROFILE', 'OBJECT').mockResolvedValue({}); 34 | client.fetchProfileDomainData.calledWith('fake_token', 'SKILLS', 'ARRAY').mockResolvedValue([]); 35 | client.fetchProfileDomainData.calledWith('fake_token', 'POSITIONS', 'ARRAY').mockResolvedValue([]); 36 | client.fetchProfileDomainData.calledWith('fake_token', 'EDUCATION', 'ARRAY').mockResolvedValue([]); 37 | 38 | const { isEmptyProfile } = await service.getLinkedinProfile('fake_token'); 39 | expect(isEmptyProfile).toBe(true); 40 | }); 41 | }); 42 | -------------------------------------------------------------------------------- /src/services/linkedin-profile.service.ts: -------------------------------------------------------------------------------- 1 | import { 2 | LinkedinProfile, 3 | LinkedinProfileEducation, 4 | LinkedinProfilePosition, 5 | LinkedinProfileProfile, 6 | LinkedinProfileSkill 7 | } from '../domain/linkedin-profile'; 8 | import { logger } from '../util/logger'; 9 | import { LinkedinAPIClient } from './linkedin-api.client'; 10 | 11 | export class LinkedinProfileService { 12 | private readonly client = new LinkedinAPIClient(); 13 | 14 | public constructor() {} 15 | 16 | public async getLinkedinProfile(token: string): Promise<{ linkedinProfile: LinkedinProfile; isEmptyProfile: boolean; timeElapsed: number }> { 17 | const startTime = Date.now(); 18 | const profile = await this.client.fetchProfileDomainData(token, 'PROFILE', 'OBJECT'); 19 | const skills = await this.client.fetchProfileDomainData(token, 'SKILLS', 'ARRAY'); 20 | const positions = await this.client.fetchProfileDomainData(token, 'POSITIONS', 'ARRAY'); 21 | const education = await this.client.fetchProfileDomainData(token, 'EDUCATION', 'ARRAY'); 22 | 23 | const linkedinProfile = { profile, skills, positions, education }; 24 | const isEmptyProfile = this.isEmptyProfile(profile); 25 | const timeElapsed = Date.now() - startTime; // in milliseconds 26 | logger.debug(`🧐 [LinkedinProfileService] Linkedin profile retrieved: ${JSON.stringify(linkedinProfile)}`); 27 | 28 | return { linkedinProfile, isEmptyProfile, timeElapsed }; 29 | } 30 | 31 | // --- 🔐 Private methods 32 | 33 | private isEmptyProfile(profile: LinkedinProfileProfile): boolean { 34 | const json = JSON.stringify(profile); 35 | return json === '{}'; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/services/queue.client.spec.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-require-imports */ 2 | import { SQSClient } from '@aws-sdk/client-sqs'; 3 | import { createMockedLinkedinProfileRequest, createMockedLinkedinProfileResponse } from '../test/mocks/linkedin-profile.mocks'; 4 | import { Environment } from '../util/environment'; 5 | import { QueueClient } from './queue.client'; 6 | 7 | jest.mock('@aws-sdk/client-sqs'); 8 | 9 | describe('QueueClient', () => { 10 | const mockSend = jest.fn(); 11 | const mockEnvironment: Environment = { 12 | AWS_RESULT_QUEUE_URL: 'https://sqs.fake/result-queue', 13 | AWS_QUEUE_URL: 'https://sqs.fake/queue', 14 | AWS_REGION: 'us-east-1', 15 | AWS_SQS_ENDPOINT: 'http://localhost:4566', 16 | MAX_RETRIES: 3, 17 | LOGGER_CONSOLE: true 18 | }; 19 | 20 | beforeAll(() => { 21 | (SQSClient as jest.Mock).mockImplementation(() => ({ 22 | send: mockSend 23 | })); 24 | }); 25 | 26 | beforeEach(() => { 27 | jest.clearAllMocks(); 28 | }); 29 | 30 | it('should send a message to the result queue', async () => { 31 | const response = createMockedLinkedinProfileResponse(); 32 | 33 | const spySendMessageCommand = jest.spyOn(require('@aws-sdk/client-sqs'), 'SendMessageCommand'); 34 | 35 | await QueueClient.sendToResultQueue(response, mockEnvironment); 36 | 37 | expect(spySendMessageCommand).toHaveBeenCalledWith( 38 | expect.objectContaining({ 39 | MessageBody: JSON.stringify(response), 40 | QueueUrl: mockEnvironment.AWS_RESULT_QUEUE_URL, 41 | MessageGroupId: expect.any(String), 42 | MessageDeduplicationId: expect.any(String) 43 | }) 44 | ); 45 | }); 46 | 47 | it('should remove a message from the queue', async () => { 48 | const receiptHandle = 'fake-receipt-handle'; 49 | const spyDeleteMessageCommand = jest.spyOn(require('@aws-sdk/client-sqs'), 'DeleteMessageCommand'); 50 | 51 | await QueueClient.removeMessage(receiptHandle, mockEnvironment); 52 | 53 | expect(spyDeleteMessageCommand).toHaveBeenCalledWith( 54 | expect.objectContaining({ 55 | QueueUrl: mockEnvironment.AWS_QUEUE_URL, 56 | ReceiptHandle: receiptHandle 57 | }) 58 | ); 59 | }); 60 | 61 | it('should resend a message to the queue', async () => { 62 | const request = createMockedLinkedinProfileRequest(); 63 | const spySendMessageCommand = jest.spyOn(require('@aws-sdk/client-sqs'), 'SendMessageCommand'); 64 | 65 | await QueueClient.resendMessage(request, mockEnvironment); 66 | 67 | expect(spySendMessageCommand).toHaveBeenCalledWith( 68 | expect.objectContaining({ 69 | MessageBody: JSON.stringify({ ...request, attempt: 2 }), 70 | QueueUrl: mockEnvironment.AWS_QUEUE_URL, 71 | MessageGroupId: expect.any(String), 72 | MessageDeduplicationId: expect.any(String) 73 | }) 74 | ); 75 | }); 76 | }); 77 | -------------------------------------------------------------------------------- /src/services/queue.client.ts: -------------------------------------------------------------------------------- 1 | import { DeleteMessageCommand, SendMessageCommand, SendMessageCommandInput, SQSClient } from '@aws-sdk/client-sqs'; 2 | import { v4 as uuid } from 'uuid'; 3 | import { LinkedinProfileRequest } from '../contracts/linkedin-profile.request'; 4 | import { LinkedinProfileResponse } from '../contracts/linkedin-profile.response'; 5 | import { Environment } from '../util/environment'; 6 | import { logger } from '../util/logger'; 7 | 8 | export class QueueClient { 9 | public static async sendToResultQueue(result: LinkedinProfileResponse, environment: Environment): Promise { 10 | const queueUrl = environment.AWS_RESULT_QUEUE_URL; 11 | const region = environment.AWS_REGION; 12 | const endpoint = environment.AWS_SQS_ENDPOINT; 13 | const messageClient = new SQSClient({ region, endpoint }); 14 | 15 | const message: SendMessageCommandInput = { 16 | MessageBody: JSON.stringify(result), 17 | QueueUrl: queueUrl, 18 | MessageGroupId: uuid(), 19 | MessageDeduplicationId: uuid() 20 | }; 21 | 22 | if (queueUrl) { 23 | logger.info(`💌 [QueueClient] Sending message to result queue: ${queueUrl}`, message); 24 | const messageCommand = new SendMessageCommand(message); 25 | await messageClient.send(messageCommand); 26 | } else { 27 | logger.warn(`💌 [QueueClient] Sending message to result queue: no queue provided`, message); 28 | } 29 | } 30 | 31 | public static async resendMessage(request: LinkedinProfileRequest, environment: Environment): Promise { 32 | const attempt = request.attempt + 1; 33 | 34 | const queueUrl = environment.AWS_QUEUE_URL; 35 | const region = environment.AWS_REGION; 36 | const endpoint = environment.AWS_SQS_ENDPOINT; 37 | const messageClient = new SQSClient({ region, endpoint }); 38 | 39 | const message: SendMessageCommandInput = { 40 | MessageBody: JSON.stringify({ ...request, attempt }), 41 | QueueUrl: queueUrl, 42 | MessageGroupId: uuid(), 43 | MessageDeduplicationId: uuid() 44 | }; 45 | 46 | if (queueUrl) { 47 | logger.info(`💌 [QueueClient] Sending message again to queue (attempt: ${attempt}): ${queueUrl}`, message); 48 | const messageCommand = new SendMessageCommand(message); 49 | await messageClient.send(messageCommand); 50 | } else { 51 | logger.warn(`💌 [QueueClient] Sending message again to queue: no queue provided`, message); 52 | } 53 | } 54 | 55 | public static async removeMessage(receiptHandle: string, environment: Environment): Promise { 56 | const queueUrl = environment.AWS_QUEUE_URL; 57 | const region = environment.AWS_REGION; 58 | const endpoint = environment.AWS_SQS_ENDPOINT; 59 | const messageClient = new SQSClient({ region, endpoint }); 60 | 61 | if (queueUrl) { 62 | logger.info(`💌 [QueueClient] Remove message with receiptHandle: ${receiptHandle} from queue: ${queueUrl}`); 63 | await messageClient.send(new DeleteMessageCommand({ QueueUrl: queueUrl, ReceiptHandle: receiptHandle })); 64 | } else { 65 | logger.warn(`💌 [QueueClient] Remove message with receiptHandle: ${receiptHandle} from queue: no queue provided`); 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/test/mocks/linkedin-profile.mocks.ts: -------------------------------------------------------------------------------- 1 | import { LinkedinProfileRequest } from '../../contracts/linkedin-profile.request'; 2 | import { LinkedinProfileResponse, LinkedinProfileResponseMac } from '../../contracts/linkedin-profile.response'; 3 | import { LinkedinProfile } from '../../domain/linkedin-profile'; 4 | 5 | export const createMockedLinkedinProfileRequest = (customValues: Partial = {}): LinkedinProfileRequest => { 6 | const request = new LinkedinProfileRequest(); 7 | request.messageId = customValues.messageId ?? '059f36b4-87a3-44ab-83d2-661975830a7d'; 8 | request.importId = customValues.importId ?? '1'; 9 | request.contextId = customValues.contextId ?? '1234'; 10 | request.env = customValues.env ?? 'local'; 11 | request.profileId = customValues.profileId ?? 356; 12 | request.linkedinApiToken = customValues.linkedinApiToken ?? 'fake-token'; 13 | request.linkedinProfileUrl = customValues.linkedinProfileUrl ?? 'https://www.linkedin.com/in/username'; 14 | request.attempt = customValues.attempt ?? 1; 15 | return request; 16 | }; 17 | 18 | export const createMockedLinkedinProfile = (customValues: Partial = {}): LinkedinProfile => { 19 | const profile = new LinkedinProfile(); 20 | 21 | profile.profile = customValues.profile ?? { 22 | 'First Name': 'Pedro', 23 | 'Last Name': 'Manfredo', 24 | Headline: 'Software Engineer @Manfred', 25 | Summary: 'I like to learn new things every day' 26 | }; 27 | 28 | profile.skills = customValues.skills ?? [{ Name: 'TypeScript' }]; 29 | 30 | profile.positions = customValues.positions ?? [ 31 | { 32 | Title: 'Senior Backend Developer', 33 | 'Company Name': 'Manfred', 34 | Description: 'Product development implemented in Node, Express, Nest.js, Typescript, Postgres, Kubernetes, AWS: SQS, Lambda, S3...', 35 | 'Started On': 'Mar 2022', 36 | 'Finished On': 'Mar 2026' 37 | } 38 | ]; 39 | 40 | profile.education = customValues.education ?? [ 41 | { 42 | 'School Name': 'University of A Coruna', 43 | 'Degree Name': 'Computer Engineering', 44 | 'Start Date': '2005', 45 | 'End Date': '2012' 46 | } 47 | ]; 48 | 49 | return profile; 50 | }; 51 | 52 | export const createMockedLinkedinProfileEmpty = (): LinkedinProfile => { 53 | const profile = new LinkedinProfile(); 54 | return profile; 55 | }; 56 | 57 | export const createMockedLinkedinProfileResponseMac = (customValues: Partial = {}): LinkedinProfileResponseMac => { 58 | const mac = new LinkedinProfileResponseMac(); 59 | mac.$schema = customValues.$schema ?? 'https://raw.githubusercontent.com/getmanfred/mac/v0.5/schema/schema.json'; 60 | mac.settings = customValues.settings ?? { language: 'EN' }; 61 | mac.aboutMe = customValues.aboutMe ?? { 62 | profile: { 63 | name: 'Pedro', 64 | surnames: 'Manfredo', 65 | title: 'Software Engineer @Manfred', 66 | description: 'I like to learn new things every day' 67 | } 68 | }; 69 | mac.experience = customValues.experience ?? {}; 70 | mac.knowledge = customValues.knowledge ?? {}; 71 | return mac; 72 | }; 73 | 74 | export const createMockedLinkedinProfileResponse = (customValues: Partial = {}): LinkedinProfileResponse => { 75 | const response = new LinkedinProfileResponse(); 76 | response.importId = customValues.importId ?? '1'; 77 | response.contextId = customValues.contextId ?? '1234'; 78 | response.profileId = customValues.profileId ?? 356; 79 | response.timeElapsed = customValues.timeElapsed ?? 1000; 80 | response.profile = customValues.profile ?? createMockedLinkedinProfileResponseMac(); 81 | return response; 82 | }; 83 | -------------------------------------------------------------------------------- /src/test/mocks/sqs.mocks.ts: -------------------------------------------------------------------------------- 1 | import { SQSEvent } from 'aws-lambda'; 2 | import { LinkedinProfileRequest } from '../../contracts/linkedin-profile.request'; 3 | import { createMockedLinkedinProfileRequest } from './linkedin-profile.mocks'; 4 | 5 | export const createMockedSqsSEvent = (customRequest?: Partial): SQSEvent => { 6 | const request = customRequest ?? createMockedLinkedinProfileRequest(); 7 | const event = { 8 | Records: [ 9 | { 10 | messageId: '059f36b4-87a3-44ab-83d2-661975830a7d', 11 | receiptHandle: 'AQEBwJnKyrHigUMZj6rYigCgxlaS3SLy0a...', 12 | body: JSON.stringify(request), 13 | attributes: { 14 | ApproximateReceiveCount: '1', 15 | SentTimestamp: '1545082649183', 16 | SenderId: 'AIDAIENQZJOLO23YVJ4VO', 17 | ApproximateFirstReceiveTimestamp: '1545082649185' 18 | }, 19 | messageAttributes: {}, 20 | md5OfBody: '098f6bcd4621d373cade4e832627b4f6', 21 | eventSource: 'aws:sqs', 22 | eventSourceARN: 'arn:aws:sqs:us-east-2:123456789012:my-queue', 23 | awsRegion: 'eu-west-1' 24 | } 25 | ] 26 | }; 27 | return event; 28 | }; 29 | -------------------------------------------------------------------------------- /src/util/__snapshots__/environment.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Environment config should correctly load environment variables for "pro" environment and match snapshot 1`] = ` 4 | Environment { 5 | "AWS_QUEUE_URL": "https://sqs-pro.amazonaws.com/1234567890/pro-queue", 6 | "AWS_REGION": "us-east-1", 7 | "AWS_RESULT_QUEUE_URL": "https://sqs-pro.amazonaws.com/1234567890/pro-result-queue", 8 | "AWS_SQS_ENDPOINT": "http://localhost:4566", 9 | "LOCAL_LINKEDIN_API_TOKEN": "mock-api-token", 10 | "LOGGER_CONSOLE": true, 11 | "MAX_RETRIES": 5, 12 | } 13 | `; 14 | 15 | exports[`Environment config should correctly load environment variables for "stage" environment and match snapshot 1`] = ` 16 | Environment { 17 | "AWS_QUEUE_URL": "https://sqs-stage.amazonaws.com/1234567890/stage-queue", 18 | "AWS_REGION": "us-east-1", 19 | "AWS_RESULT_QUEUE_URL": "https://sqs-stage.amazonaws.com/1234567890/stage-result-queue", 20 | "AWS_SQS_ENDPOINT": "http://localhost:4566", 21 | "LOCAL_LINKEDIN_API_TOKEN": "mock-api-token", 22 | "LOGGER_CONSOLE": true, 23 | "MAX_RETRIES": 5, 24 | } 25 | `; 26 | 27 | exports[`Environment config should throw an error when environment variables are invalid 1`] = `"🔧 [Environment] Invalid environment variables: process.exit called"`; 28 | -------------------------------------------------------------------------------- /src/util/date.spec.ts: -------------------------------------------------------------------------------- 1 | import { DateUtilities } from './date'; 2 | 3 | describe('date utilities', () => { 4 | beforeAll(() => { 5 | jest.useFakeTimers().setSystemTime(new Date('2023-10-01')); 6 | }); 7 | 8 | afterAll(() => { 9 | jest.useRealTimers(); 10 | }); 11 | 12 | describe('when calling toIsoDate', () => { 13 | it('should correctly format a date in `MMM yyyy` format to ISO format', () => { 14 | const date = 'Mar 2026'; 15 | const expected = '2026-03-01'; 16 | 17 | const result = DateUtilities.toIsoDate(date); 18 | expect(result).toBe(expected); 19 | }); 20 | 21 | it('should correctly format a date in `yyyy` format to ISO format', () => { 22 | const date = '2026'; 23 | const expected = '2026-01-01'; 24 | 25 | const result = DateUtilities.toIsoDate(date); 26 | expect(result).toBe(expected); 27 | }); 28 | 29 | it("should return today's date in ISO format when the date is not provided", () => { 30 | const date = undefined; 31 | const expected = '2023-10-01'; 32 | 33 | const result = DateUtilities.toIsoDate(date); 34 | expect(result).toBe(expected); 35 | }); 36 | 37 | it("should return today's date in ISO format when the date is invalid", () => { 38 | const date = 'invalid date'; 39 | const expected = '2023-10-01'; 40 | 41 | const result = DateUtilities.toIsoDate(date); 42 | expect(result).toBe(expected); 43 | }); 44 | }); 45 | }); 46 | -------------------------------------------------------------------------------- /src/util/date.ts: -------------------------------------------------------------------------------- 1 | import { format, parse } from 'date-fns'; 2 | import { enUS } from 'date-fns/locale'; 3 | import { logger } from './logger'; 4 | 5 | export class DateUtilities { 6 | public static toIsoDate(date: string | undefined): string { 7 | const dateFormats = ['MMM yyyy', 'yyyy']; 8 | const now = format(new Date(), 'yyyy-MM-dd'); 9 | 10 | if (!date) { 11 | logger.warn(`[DateUtilities] Date is not provided, so returning today's date`); 12 | return now; 13 | } 14 | 15 | for (const dateFormat of dateFormats) { 16 | try { 17 | const parsedDate = parse(date, dateFormat, new Date(), { locale: enUS }); 18 | if (!isNaN(parsedDate.getTime())) return format(parsedDate, 'yyyy-MM-dd'); 19 | } catch { 20 | continue; 21 | } 22 | } 23 | 24 | logger.warn(`[DateUtilities] Could not parse date: ${date}, so returning today's date`); 25 | return now; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/util/environment.spec.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-process-env */ 2 | import { createMockedLinkedinProfileRequest } from '../test/mocks/linkedin-profile.mocks'; 3 | import { Environment } from './environment'; 4 | 5 | describe('Environment config', () => { 6 | const mockEnv = { 7 | MAX_RETRIES: '5', 8 | LOGGER_CONSOLE: 'true', 9 | LOCAL_LINKEDIN_API_TOKEN: 'mock-api-token', 10 | AWS_REGION: 'us-east-1', 11 | AWS_SQS_ENDPOINT: 'http://localhost:4566', 12 | AWS_QUEUE_URL_STAGE: 'https://sqs-stage.amazonaws.com/1234567890/stage-queue', 13 | AWS_RESULT_QUEUE_URL_STAGE: 'https://sqs-stage.amazonaws.com/1234567890/stage-result-queue', 14 | AWS_QUEUE_URL_PRO: 'https://sqs-pro.amazonaws.com/1234567890/pro-queue', 15 | AWS_RESULT_QUEUE_URL_PRO: 'https://sqs-pro.amazonaws.com/1234567890/pro-result-queue' 16 | }; 17 | 18 | beforeEach(() => { 19 | process.env = { ...mockEnv }; 20 | }); 21 | 22 | afterEach(() => { 23 | jest.resetModules(); 24 | }); 25 | 26 | it('should correctly load environment variables for "stage" environment and match snapshot', () => { 27 | const request = createMockedLinkedinProfileRequest({ env: 'stage' }); 28 | 29 | const environment = Environment.setupEnvironment(request); 30 | 31 | expect(environment).toMatchSnapshot(); 32 | }); 33 | 34 | it('should correctly load environment variables for "pro" environment and match snapshot', () => { 35 | const request = createMockedLinkedinProfileRequest({ env: 'pro' }); 36 | 37 | const environment = Environment.setupEnvironment(request); 38 | 39 | expect(environment).toMatchSnapshot(); 40 | }); 41 | 42 | it('should throw an error when environment variables are invalid', () => { 43 | const mockExit = jest.spyOn(process, 'exit').mockImplementation(() => { 44 | throw new Error('process.exit called'); 45 | }); 46 | 47 | process.env.MAX_RETRIES = 'invalid'; 48 | 49 | const request = createMockedLinkedinProfileRequest({ env: 'pro' }); 50 | 51 | expect(() => Environment.setupEnvironment(request)).toThrowErrorMatchingSnapshot(); 52 | 53 | mockExit.mockRestore(); 54 | }); 55 | }); 56 | -------------------------------------------------------------------------------- /src/util/environment.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-process-env */ 2 | import { bool, cleanEnv, num, str } from 'envalid'; 3 | import { LinkedinProfileRequest } from '../contracts/linkedin-profile.request'; 4 | import { EnvironmentType } from '../domain/logger-context'; 5 | import { setContextLogger } from './logger'; 6 | 7 | export class Environment { 8 | public readonly MAX_RETRIES: number; 9 | public readonly LOGGER_CONSOLE: boolean; 10 | public readonly LOCAL_LINKEDIN_API_TOKEN?: string; 11 | public readonly AWS_REGION: string; 12 | public readonly AWS_SQS_ENDPOINT?: string; 13 | public readonly AWS_QUEUE_URL?: string; 14 | public readonly AWS_RESULT_QUEUE_URL?: string; 15 | 16 | private constructor(envName: EnvironmentType) { 17 | try { 18 | const env = cleanEnv(process.env, { 19 | MAX_RETRIES: num({ default: 3 }), 20 | LOGGER_CONSOLE: bool({ default: false }), 21 | LOCAL_LINKEDIN_API_TOKEN: str({ default: undefined }), 22 | AWS_REGION: str({ default: 'eu-west-1' }), 23 | AWS_SQS_ENDPOINT: str({ default: undefined }), 24 | AWS_QUEUE_URL_STAGE: str({ default: undefined }), 25 | AWS_RESULT_QUEUE_URL_STAGE: str({ default: undefined }), 26 | AWS_QUEUE_URL_PRO: str({ default: undefined }), 27 | AWS_RESULT_QUEUE_URL_PRO: str({ default: undefined }) 28 | }); 29 | 30 | this.MAX_RETRIES = env.MAX_RETRIES; 31 | this.LOGGER_CONSOLE = env.LOGGER_CONSOLE; 32 | this.LOCAL_LINKEDIN_API_TOKEN = env.LOCAL_LINKEDIN_API_TOKEN ?? undefined; 33 | 34 | this.AWS_REGION = env.AWS_REGION; 35 | this.AWS_SQS_ENDPOINT = env.AWS_SQS_ENDPOINT; 36 | this.AWS_QUEUE_URL = envName === 'pro' ? env.AWS_QUEUE_URL_PRO : env.AWS_QUEUE_URL_STAGE; 37 | this.AWS_RESULT_QUEUE_URL = envName === 'pro' ? env.AWS_RESULT_QUEUE_URL_PRO : env.AWS_RESULT_QUEUE_URL_STAGE; 38 | } catch (error: unknown) { 39 | throw new Error(`🔧 [Environment] Invalid environment variables: ${(error as Error)?.message}`); 40 | } 41 | } 42 | 43 | public static setupEnvironment(request: LinkedinProfileRequest): Environment { 44 | const environment = new Environment(request.env); 45 | setContextLogger({ 46 | messageId: request.messageId, 47 | contextId: request.contextId, 48 | profileId: request.profileId, 49 | env: request.env 50 | }); 51 | return environment; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/util/logger.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-process-env */ 2 | import winston from 'winston'; 3 | import { LoggerContext } from '../domain/logger-context'; 4 | 5 | const isConsoleLoggerEnabled = process.env.LOGGER_CONSOLE === 'true'; 6 | 7 | let messageId = ''; 8 | let contextId = ''; 9 | let profileId = -999; 10 | let env = 'local'; 11 | 12 | const addContext: winston.Logform.TransformFunction = (info): winston.Logform.TransformableInfo => { 13 | info.message = info.message; 14 | info.messageId = messageId; 15 | info.contextId = contextId; 16 | info.profileId = profileId; 17 | info.environment = env; 18 | return info; 19 | }; 20 | 21 | const jsonLogger = new winston.transports.Console({ 22 | format: winston.format.combine( 23 | winston.format.timestamp({ 24 | format: 'YYYY-MM-DD HH:mm:ss' 25 | }), 26 | winston.format.uncolorize(), 27 | winston.format(addContext)(), 28 | winston.format.splat(), 29 | winston.format.simple(), 30 | winston.format.json() 31 | ), 32 | level: 'debug' 33 | }); 34 | 35 | const consoleLogger = new winston.transports.Console({ 36 | format: winston.format.combine( 37 | winston.format.colorize(), 38 | winston.format.splat(), 39 | winston.format.printf((info) => `${info.level}: ${info.message}`) 40 | ), 41 | level: 'debug' 42 | }); 43 | 44 | const transports = isConsoleLoggerEnabled ? consoleLogger : jsonLogger; 45 | 46 | // -- Exports --- 47 | export const logger = winston.createLogger({ transports }); 48 | 49 | export const setContextLogger = (config: LoggerContext): void => { 50 | messageId = config.messageId; 51 | contextId = config.contextId; 52 | profileId = config.profileId; 53 | env = config.env; 54 | }; 55 | -------------------------------------------------------------------------------- /src/util/validation.ts: -------------------------------------------------------------------------------- 1 | import { ValidationError } from 'class-validator'; 2 | 3 | export class ValidationUtilities { 4 | public static formatErrors(errors: ValidationError[]): string[] { 5 | const processError = (err: ValidationError, parentPath: string = ''): string[] => { 6 | const propertyPath = parentPath ? `${parentPath}.${err.property}` : err.property; 7 | 8 | const currentError = err.constraints ? [`property: ${propertyPath} errors: ${Object.values(err.constraints).join(', ')}`] : []; 9 | 10 | const childErrors = err.children ? err.children.flatMap((child) => processError(child, propertyPath)) : []; 11 | 12 | return [...currentError, ...childErrors]; 13 | }; 14 | 15 | return errors.flatMap((err) => processError(err)); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2016", 4 | "module": "commonjs", 5 | "strict": true, 6 | "outDir": "./dist", 7 | "experimentalDecorators": true, 8 | "emitDecoratorMetadata": true, 9 | "noUnusedLocals": true, 10 | "noUnusedParameters": true, 11 | "noImplicitReturns": true, 12 | "noFallthroughCasesInSwitch": true, 13 | "esModuleInterop": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "skipLibCheck": true 16 | }, 17 | "include": ["src/**/*"], 18 | "exclude": ["node_modules"] 19 | } 20 | --------------------------------------------------------------------------------