├── .eslintignore ├── .eslintrc.json ├── .github ├── CONTRIBUTING.md ├── ISSUE_TEMPLATE.md ├── PULL_REQUEST_TEMPLATE.md └── workflows │ └── node.js.yml ├── .gitignore ├── .npmrc ├── .releaserc.json ├── .vscode ├── launch.json └── settings.json ├── CODE_OF_CONDUCT.md ├── COPYRIGHT ├── LICENSE ├── README.md ├── bin └── validate_spec.js ├── docs └── readme_template.md ├── e2e ├── .env_example ├── .eslintrc.json ├── README.md ├── e2e.error.js ├── e2e.js ├── jest.config.js └── storage.js ├── package.json ├── spec └── api.json ├── src ├── SDKErrors.js ├── fileresolver.js ├── helpers.js ├── index.js ├── job.js └── types.js ├── templates └── code-header.js ├── test ├── .eslintrc.json ├── fileresolver.test.js ├── helpers.test.js ├── index.test.js ├── jest.config.js ├── jest │ ├── jest.fetch.setup.js │ ├── jest.fixture.setup.js │ ├── jest.fs.setup.js │ ├── jest.setup.js │ ├── jest.swagger.setup.js │ └── mocks │ │ └── swagger-client.js └── job.test.js ├── testfiles ├── Auto-BW.xmp ├── Example.jpg ├── Example.psd ├── Layer Comps.psd ├── Sunflower.psd └── heroImage.png └── types.d.ts /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["@adobe/eslint-config-aio-lib-config"], 3 | "plugins": [ 4 | "eslint-plugin-notice" 5 | ], 6 | "rules": { 7 | "strict": ["error", "global"], 8 | "notice/notice": [ "warn", { 9 | "templateFile": "./templates/code-header.js" 10 | } ] 11 | } 12 | } -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Thanks for choosing to contribute! 4 | 5 | The following are a set of guidelines to follow when contributing to this project. 6 | 7 | ## Code Of Conduct 8 | 9 | This project adheres to the Adobe [code of conduct](../CODE_OF_CONDUCT.md). By participating, 10 | you are expected to uphold this code. Please report unacceptable behavior to 11 | [Grp-opensourceoffice@adobe.com](mailto:Grp-opensourceoffice@adobe.com). 12 | 13 | ## Have A Question? 14 | 15 | Start by filing an issue. The existing committers on this project work to reach 16 | consensus around project direction and issue solutions within issue threads 17 | (when appropriate). 18 | 19 | ## Contributor License Agreement 20 | 21 | All third-party contributions to this project must be accompanied by a signed contributor 22 | license agreement. This gives Adobe permission to redistribute your contributions 23 | as part of the project. [Sign our CLA](http://opensource.adobe.com/cla.html). You 24 | only need to submit an Adobe CLA one time, so if you have submitted one previously, 25 | you are good to go! 26 | 27 | ## Code Reviews 28 | 29 | All submissions should come in the form of pull requests and need to be reviewed 30 | by project committers. Read [GitHub's pull request documentation](https://help.github.com/articles/about-pull-requests/) 31 | for more information on sending pull requests. 32 | 33 | Lastly, please follow the [pull request template](PULL_REQUEST_TEMPLATE.md) when 34 | submitting a pull request! 35 | 36 | ## From Contributor To Committer 37 | 38 | We love contributions from our community! If you'd like to go a step beyond contributor 39 | and become a committer with full write access and a say in the project, you must 40 | be invited to the project. The existing committers employ an internal nomination 41 | process that must reach lazy consensus (silence is approval) before invitations 42 | are issued. If you feel you are qualified and want to get more deeply involved, 43 | feel free to reach out to existing committers to have a conversation about that. 44 | 45 | ## Security Issues 46 | 47 | Security issues shouldn't be reported on this issue tracker. Instead, [file an issue to our security experts](https://helpx.adobe.com/security/alertus.html) 48 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | ### Expected Behaviour 5 | 6 | ### Actual Behaviour 7 | 8 | ### Reproduce Scenario (including but not limited to) 9 | 10 | #### Steps to Reproduce 11 | 12 | #### Platform and Version 13 | 14 | #### Sample Code that illustrates the problem 15 | 16 | #### Logs taken while reproducing problem 17 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## Description 4 | 5 | 6 | 7 | ## Related Issue 8 | 9 | 10 | 11 | 12 | 13 | 14 | ## Motivation and Context 15 | 16 | 17 | 18 | ## How Has This Been Tested? 19 | 20 | 21 | 22 | 23 | 24 | ## Screenshots (if appropriate): 25 | 26 | ## Types of changes 27 | 28 | 29 | 30 | - [ ] Bug fix (non-breaking change which fixes an issue) 31 | - [ ] New feature (non-breaking change which adds functionality) 32 | - [ ] Breaking change (fix or feature that would cause existing functionality to change) 33 | 34 | ## Checklist: 35 | 36 | 37 | 38 | 39 | - [ ] I have signed the [Adobe Open Source CLA](http://opensource.adobe.com/cla.html). 40 | - [ ] My code follows the code style of this project. 41 | - [ ] My change requires a change to the documentation. 42 | - [ ] I have updated the documentation accordingly. 43 | - [ ] I have read the **CONTRIBUTING** document. 44 | - [ ] I have added tests to cover my changes. 45 | - [ ] All new and existing tests passed. 46 | -------------------------------------------------------------------------------- /.github/workflows/node.js.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Node.js CI 5 | 6 | on: 7 | push: 8 | branches: [ master ] 9 | pull_request: 10 | branches: [ master ] 11 | 12 | jobs: 13 | build: 14 | runs-on: ${{ matrix.os }} 15 | strategy: 16 | matrix: 17 | node-version: [10.x, 12.x, 14.x] 18 | os: [ubuntu-latest, windows-latest] 19 | 20 | steps: 21 | - uses: actions/checkout@v2 22 | - name: Use Node.js ${{ matrix.node-version }} 23 | uses: actions/setup-node@v1 24 | with: 25 | node-version: ${{ matrix.node-version }} 26 | - run: npm i --package-lock --package-lock-only 27 | - run: npm ci 28 | - run: npm run build --if-present 29 | - run: npm test 30 | - name: upload coverage 31 | if: success() 32 | run: curl -s https://codecov.io/bash | bash 33 | env: 34 | CODECOV_NAME: ${{ runner.os }} node.js ${{ matrix.node-version }} 35 | shell: bash 36 | 37 | semantic-release: 38 | runs-on: ubuntu-latest 39 | if: ${{ !contains(github.event.head_commit.message, '[ci skip]') && github.ref == 'refs/heads/master' }} 40 | steps: 41 | - uses: actions/checkout@v2 42 | with: 43 | persist-credentials: false 44 | - name: Use Node.js 14.18 45 | uses: actions/setup-node@v1 46 | with: 47 | node-version: '14.18' 48 | - run: npm install 49 | - run: npm run semantic-release 50 | env: 51 | GITHUB_TOKEN: ${{ secrets.ADOBE_BOT_GITHUB_TOKEN }} 52 | NPM_TOKEN: ${{ secrets.ADOBE_BOT_NPM_TOKEN }} 53 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | package-lock.json 3 | coverage 4 | junit.xml 5 | .firefly 6 | *.env 7 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false 2 | tag-version-prefix="" 3 | -------------------------------------------------------------------------------- /.releaserc.json: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": [ 3 | ["@semantic-release/commit-analyzer", { 4 | "preset": "eslint", 5 | "releaseRules": [ 6 | { 7 | "subject": "*", 8 | "release": false 9 | }, 10 | { 11 | "subject": "FEATURE-RELEASE:*", 12 | "release": "minor" 13 | }, 14 | { 15 | "subject": "BUGFIX-RELEASE:*", 16 | "release": "patch" 17 | }, 18 | { 19 | "subject": "BREAKING-RELEASE:*", 20 | "release": "major" 21 | } 22 | ] 23 | }], 24 | ["@semantic-release/release-notes-generator", { 25 | "preset": "eslint" 26 | }], 27 | ["@semantic-release/npm"], 28 | ["@semantic-release/git", { 29 | "preset": "eslint", 30 | "assets": ["package.json", "package-lock.json"], 31 | "message": " [ci skip] no-release: version number update" 32 | }], 33 | ["@semantic-release/github"] 34 | ] 35 | } -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "configurations": [ 3 | { 4 | "type": "node", 5 | "name": "vscode-jest-tests", 6 | "request": "launch", 7 | "program": "${workspaceFolder}/node_modules/.bin/jest", 8 | "args": [ 9 | "--runInBand", 10 | "--config", 11 | "${workspaceFolder}/test/jest.config.js" 12 | ], 13 | "cwd": "${workspaceFolder}", 14 | "console": "integratedTerminal", 15 | "internalConsoleOptions": "neverOpen", 16 | "port": 9229, 17 | "disableOptimisticBPs": true, 18 | "windows": { 19 | "program": "${workspaceFolder}/node_modules/jest/bin/jest" 20 | } 21 | }, 22 | { 23 | "type": "node", 24 | "request": "attach", 25 | "name": "Attach", 26 | "port": 9229 27 | }, 28 | { 29 | "type": "node", 30 | "request": "launch", 31 | "name": "Launch File", 32 | "skipFiles": [ 33 | "/**" 34 | ], 35 | "program": "${file}" 36 | } 37 | ] 38 | } 39 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.tabSize": 2, 3 | "cSpell.words": [ 4 | "RGBA", 5 | "openapi", 6 | "sensei" 7 | ] 8 | } -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Adobe Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of experience, 9 | nationality, personal appearance, race, religion, or sexual identity and 10 | orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at Grp-opensourceoffice@adobe.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at [https://contributor-covenant.org/version/1/4][version] 72 | 73 | [homepage]: https://contributor-covenant.org 74 | [version]: https://contributor-covenant.org/version/1/4/ 75 | -------------------------------------------------------------------------------- /COPYRIGHT: -------------------------------------------------------------------------------- 1 | © Copyright 2015-2023 Adobe. All rights reserved. 2 | 3 | Adobe holds the copyright for all the files found in this repository. 4 | 5 | See the LICENSE file for licensing information. 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2020 Adobe 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /bin/validate_spec.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Adobe. All rights reserved. 3 | This file is licensed to you under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. You may obtain a copy 5 | of the License at http://www.apache.org/licenses/LICENSE-2.0 6 | Unless required by applicable law or agreed to in writing, software distributed under 7 | the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS 8 | OF ANY KIND, either express or implied. See the License for the specific language 9 | governing permissions and limitations under the License. 10 | */ 11 | 12 | 'use strict' 13 | 14 | const path = require('path') 15 | 16 | let arg = '../spec/api.json' 17 | if (process.argv.length > 2) { 18 | arg = path.resolve(process.argv[2]) 19 | } 20 | 21 | var OpenAPISchemaValidator = require('openapi-schema-validator').default 22 | var validator = new OpenAPISchemaValidator({ 23 | version: 3 24 | }) 25 | 26 | const apiDoc = require(arg) 27 | const result = validator.validate(apiDoc) 28 | 29 | if (result.errors.length > 0) { 30 | console.log(JSON.stringify(result, null, 4)) 31 | throw Error(`Failed to validate: ${arg}`) 32 | } 33 | -------------------------------------------------------------------------------- /docs/readme_template.md: -------------------------------------------------------------------------------- 1 | 12 | 13 | [![Version](https://img.shields.io/npm/v/@adobe/aio-lib-photoshop-api.svg)](https://npmjs.org/package/@adobe/aio-lib-photoshop-api) 14 | [![Downloads/week](https://img.shields.io/npm/dw/@adobe/aio-lib-photoshop-api.svg)](https://npmjs.org/package/@adobe/aio-lib-photoshop-api) 15 | [![Build Status](https://travis-ci.com/adobe/aio-lib-photoshop-api.svg?branch=master)](https://travis-ci.com/adobe/aio-lib-photoshop-api) 16 | [![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) 17 | [![Codecov Coverage](https://img.shields.io/codecov/c/github/adobe/aio-lib-photoshop-api/master.svg?style=flat-square)](https://codecov.io/gh/adobe/aio-lib-photoshop-api/) 18 | 19 | # Adobe Photoshop API Library 20 | 21 | ### Rest API 22 | 23 | The Rest API is documented at: 24 | 25 | - [Public Documentation](https://adobedocs.github.io/photoshop-api-docs-pre-release/#api-Photoshop) 26 | 27 | ### Installing 28 | 29 | ```bash 30 | $ npm install @adobe/aio-lib-photoshop-api 31 | ``` 32 | 33 | ### Usage 34 | 35 | 1) Initialize the SDK 36 | 37 | ```javascript 38 | const sdk = require('@adobe/aio-lib-photoshop-api') 39 | 40 | async function sdkTest() { 41 | try { 42 | //initialize sdk 43 | const client = await sdk.init('', '', '') 44 | } catch (e) { 45 | console.error(e) 46 | } 47 | } 48 | ``` 49 | 50 | 2) Remove the background of a photo 51 | 52 | This is the example of using the storage type of `http://host/input.jpg` (External) and call the service to cutout the background, ask for JPEG output, and store the result in Adobe Creative Cloud file storage `path/output.jpg`. 53 | 54 | ```javascript 55 | const sdk = require('@adobe/aio-lib-photoshop-api') 56 | 57 | async function sdkTest() { 58 | try { 59 | // initialize sdk 60 | const client = await sdk.init('', '', '') 61 | 62 | // call methods 63 | const result = await client.createCutout({ 64 | href: 'http://host/input.jpg', 65 | storage: sdk.Storage.EXTERNAL 66 | }, { 67 | href: 'path/output.png', 68 | storage: sdk.Storage.ADOBE, 69 | type: sdk.MimeType.PNG 70 | }) 71 | } catch (e) { 72 | console.error(e) 73 | } 74 | } 75 | ``` 76 | 77 | ### Usage with Adobe I/O Files access 78 | 79 | 1) Initialize the SDK with Adobe I/O Files access 80 | 81 | Configuring the SDK like this will make plain paths reference locations in Adobe I/O Files. 82 | 83 | ```javascript 84 | const libFiles = require('@adobe/aio-lib-files') 85 | const sdk = require('@adobe/aio-lib-photoshop-api') 86 | 87 | async function sdkTest() { 88 | try { 89 | // initialize sdk 90 | const files = await libFiles.init(); 91 | const client = await sdk.init('', '', '', files) 92 | } catch (e) { 93 | console.error(e) 94 | } 95 | } 96 | ``` 97 | 98 | 2) Remove the background of a photo 99 | 100 | This will automatically detect the storage type of `http://host/input.jpg` (e.g. Azure, External) and call the service to cutout the background, ask for JPEG output, and store the result in Adobe I/O Files under `path/output.jpg`. 101 | 102 | ```javascript 103 | const libFiles = require('@adobe/aio-lib-files') 104 | const sdk = require('@adobe/aio-lib-photoshop-api') 105 | 106 | async function sdkTest() { 107 | try { 108 | // initialize sdk 109 | const files = await libFiles.init(); 110 | const client = await sdk.init('', '', '', files) 111 | 112 | // call methods 113 | // auto cutout... 114 | const result = await client.createCutout('http://host/input.jpg', 'path/output.jpg') 115 | console.log(result) 116 | 117 | // equivalent call without FileResolver... 118 | const result = await client.createCutout({ 119 | href: 'http://host/input.jpg', 120 | storage: sdk.Storage.EXTERNAL 121 | }, { 122 | href: 'path/output.png', 123 | storage: sdk.Storage.AIO, 124 | type: sdk.MimeType.PNG 125 | }) 126 | } catch (e) { 127 | console.error(e) 128 | } 129 | } 130 | ``` 131 | 132 | {{>main-index~}} 133 | {{>all-docs~}} 134 | 135 | 136 | ### Debug Logs 137 | 138 | ```bash 139 | LOG_LEVEL=debug 140 | ``` 141 | 142 | Prepend the `LOG_LEVEL` environment variable and `debug` value to the call that invokes your function, on the command line. This should output a lot of debug data for your SDK calls. 143 | 144 | ### Contributing 145 | 146 | Contributions are welcome! Read the [Contributing Guide](./.github/CONTRIBUTING.md) for more information. 147 | 148 | ### Licensing 149 | 150 | This project is licensed under the Apache V2 License. See [LICENSE](LICENSE) for more information. 151 | -------------------------------------------------------------------------------- /e2e/.env_example: -------------------------------------------------------------------------------- 1 | # To run the e2e tests, use the following steps: 2 | # 3 | # 1. Copy this file to ".env" in the same directory. 4 | # 2. Fill out the IMS organization, API key, and Access Token 5 | # 3. Fill out the Azure credentials 6 | # 4. From a command prompt, execute "npm run e2e" from the root directory. 7 | 8 | # OAuth or JWT authentication information 9 | PHOTOSHOP_ORG_ID="" 10 | PHOTOSHOP_API_KEY="" 11 | PHOTOSHOP_ACCESS_TOKEN="" 12 | 13 | # Use the Azure portal to create a new storage account. They key is automatically 14 | # generated and can be downloaded from the portal. The container must be a blob 15 | # container used to upload the test files. 16 | PHOTOSHOP_AZURE_STORAGE_ACCOUNT="" 17 | PHOTOSHOP_AZURE_STORAGE_KEY="" 18 | PHOTOSHOP_AZURE_STORAGE_CONTAINER="" 19 | -------------------------------------------------------------------------------- /e2e/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "node/no-unpublished-require": 0 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /e2e/README.md: -------------------------------------------------------------------------------- 1 | # E2E Tests 2 | 3 | ## Requirements 4 | 5 | To run the e2e test you'll need these env variables set: 6 | 1. `PHOTOSHOP_TENANT_ID` 7 | 2. `PHOTOSHOP_API_KEY` 8 | 3. `PHOTOSHOP_ACCESS_TOKEN` 9 | 10 | ## Run 11 | 12 | `npm run e2e` 13 | 14 | ## Test overview 15 | 16 | The tests cover: 17 | 18 | 1. Malformed tenant id, api key or access token 19 | 2. `read` APIs 20 | -------------------------------------------------------------------------------- /e2e/e2e.error.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Adobe. All rights reserved. 3 | This file is licensed to you under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. You may obtain a copy 5 | of the License at http://www.apache.org/licenses/LICENSE-2.0 6 | Unless required by applicable law or agreed to in writing, software distributed under 7 | the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS 8 | OF ANY KIND, either express or implied. See the License for the specific language 9 | governing permissions and limitations under the License. 10 | */ 11 | 12 | 'use strict' 13 | 14 | const sdk = require('../src/index') 15 | const path = require('path') 16 | const storage = require('./storage') 17 | const { v4: uuidv4 } = require('uuid') 18 | 19 | // load .env values in the e2e folder, if any 20 | require('dotenv').config({ path: path.join(__dirname, '.env') }) 21 | 22 | /** 23 | * @type {import('../src/index').PhotoshopAPI} 24 | */ 25 | let sdkClient = {} 26 | let container = {} 27 | let testRunId = '' 28 | const orgId = process.env.PHOTOSHOP_ORG_ID 29 | const apiKey = process.env.PHOTOSHOP_API_KEY 30 | const accessToken = process.env.PHOTOSHOP_ACCESS_TOKEN 31 | 32 | beforeAll(async () => { 33 | container = await storage.init() 34 | sdkClient = await sdk.init(orgId, apiKey, accessToken, container) 35 | testRunId = uuidv4() 36 | console.error(`Test run id: ${testRunId}`) 37 | }) 38 | 39 | test('sdk init test', async () => { 40 | expect(sdkClient.orgId).toBe(orgId) 41 | expect(sdkClient.apiKey).toBe(apiKey) 42 | expect(sdkClient.accessToken).toBe(accessToken) 43 | }) 44 | 45 | test('createMask - test bad access token', async () => { 46 | const _sdkClient = await sdk.init(orgId, apiKey, 'bad_access_token') 47 | const promise = _sdkClient.createMask('input', 'output') 48 | return expect(promise).rejects.toThrow('[PhotoshopSDK:ERROR_UNAUTHORIZED] 401 - Unauthorized ({"error_code":"401013","message":"Oauth token is not valid"})') 49 | }) 50 | 51 | test('autoTone - test bad access token', async () => { 52 | const _sdkClient = await sdk.init(orgId, apiKey, 'bad_access_token') 53 | const promise = _sdkClient.autoTone('input', 'output') 54 | 55 | // just match the error message 56 | return expect(promise).rejects.toThrow('[PhotoshopSDK:ERROR_UNAUTHORIZED] 401 - Unauthorized ({"error_code":"401013","message":"Oauth token is not valid"})') 57 | }) 58 | 59 | test('createDocument - test bad access token', async () => { 60 | const _sdkClient = await sdk.init(orgId, apiKey, 'bad_access_token') 61 | const promise = _sdkClient.createDocument('output') 62 | 63 | // just match the error message 64 | return expect(promise).rejects.toThrow('[PhotoshopSDK:ERROR_UNAUTHORIZED] 401 - Unauthorized ({"error_code":"401013","message":"Oauth token is not valid"})') 65 | }) 66 | 67 | test('createMask - test bad api key', async () => { 68 | const _sdkClient = await sdk.init(orgId, 'bad_api_key', accessToken) 69 | const promise = _sdkClient.createMask('input', 'output') 70 | return expect(promise).rejects.toThrow('[PhotoshopSDK:ERROR_AUTH_FORBIDDEN] 403 - Forbidden ({"error_code":"403003","message":"Api Key is invalid"})') 71 | }) 72 | 73 | test('createMask - invalid value', async () => { 74 | const input = `${testRunId}/createMask-invalidValue-input.jpg` 75 | const output = `${testRunId}/createMask-invalidValue-output.jpg` 76 | await container.copy('./testfiles/Example.jpg', input, { localSrc: true }) 77 | const promise = sdkClient.createMask(input, { 78 | href: output, 79 | mask: { 80 | format: 'invalidValue' 81 | } 82 | }) 83 | return expect(promise).rejects.toThrow('[PhotoshopSDK:ERROR_INPUT_VALIDATION] 400 - Bad Request ({"code":400,"type":"InputValidationError","reason":[{"keyword":"enum","dataPath":".output.mask.format","schemaPath":"#/properties/output/properties/mask/properties/format/enum","params":{"allowedValues":["soft","binary"]},"message":"should be equal to one of the allowed values"}]})') 84 | }) 85 | 86 | test('createMask - invalid type', async () => { 87 | const input = `${testRunId}/createMask-invalidValue-input.jpg` 88 | const output = `${testRunId}/createMask-invalidValue-output.jpg` 89 | await container.copy('./testfiles/Example.jpg', input, { localSrc: true }) 90 | const promise = sdkClient.createMask(input, { 91 | href: output, 92 | mask: { 93 | format: 123 94 | } 95 | }) 96 | return expect(promise).rejects.toThrow('[PhotoshopSDK:ERROR_INPUT_VALIDATION] 400 - Bad Request ({"code":400,"type":"InputValidationError","reason":[{"keyword":"type","dataPath":".output.mask.format","schemaPath":"#/properties/output/properties/mask/properties/format/type","params":{"type":"string"},"message":"should be string"},{"keyword":"enum","dataPath":".output.mask.format","schemaPath":"#/properties/output/properties/mask/properties/format/enum","params":{"allowedValues":["soft","binary"]},"message":"should be equal to one of the allowed values"}]})') 97 | }) 98 | 99 | test('createMask - missing value', async () => { 100 | const input = `${testRunId}/createMask-invalidValue-input.jpg` 101 | const output = `${testRunId}/createMask-invalidValue-output.jpg` 102 | await container.copy('./testfiles/Example.jpg', input, { localSrc: true }) 103 | const promise = sdkClient.createMask(input, { 104 | href: output, 105 | mask: { } 106 | }) 107 | return expect(promise).rejects.toThrow('[PhotoshopSDK:ERROR_INPUT_VALIDATION] 400 - Bad Request ({"code":400,"type":"InputValidationError","reason":[{"keyword":"required","dataPath":".output.mask","schemaPath":"#/properties/output/properties/mask/required","params":{"missingProperty":"format"},"message":"should have required property \'format\'"}]})') 108 | }) 109 | 110 | test('test bad input and output', async () => { 111 | const _sdkClient = await sdk.init('bad_org_id', apiKey, accessToken) 112 | const promise = _sdkClient.createMask('input', 'output') 113 | 114 | // just match the error message 115 | return expect(promise).rejects.toThrow('[PhotoshopSDK:ERROR_INPUT_VALIDATION] 400 - Bad Request ({"code":400,"type":"InputValidationError","reason":[{"keyword":"pattern","dataPath":".input.href","schemaPath":"#/properties/input/else/properties/href/pattern","params":{"pattern":"^/?(files|temp|cloud-content|assets)/.+"},"message":"should match pattern \\"^/?(files|temp|cloud-content|assets)/.+\\""},{"keyword":"if","dataPath":".input","schemaPath":"#/properties/input/if","params":{"failingKeyword":"else"},"message":"should match \\"else\\" schema"},{"keyword":"pattern","dataPath":".output.href","schemaPath":"#/properties/output/else/properties/href/pattern","params":{"pattern":"^/?(files|temp|cloud-content|assets)/.+"},"message":"should match pattern \\"^/?(files|temp|cloud-content|assets)/.+\\""},{"keyword":"if","dataPath":".output","schemaPath":"#/properties/output/if","params":{"failingKeyword":"else"},"message":"should match \\"else\\" schema"}]})') 116 | }) 117 | -------------------------------------------------------------------------------- /e2e/e2e.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Adobe. All rights reserved. 3 | This file is licensed to you under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. You may obtain a copy 5 | of the License at http://www.apache.org/licenses/LICENSE-2.0 6 | Unless required by applicable law or agreed to in writing, software distributed under 7 | the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS 8 | OF ANY KIND, either express or implied. See the License for the specific language 9 | governing permissions and limitations under the License. 10 | */ 11 | 12 | 'use strict' 13 | 14 | const sdk = require('../src/index') 15 | const path = require('path') 16 | const storage = require('./storage') 17 | const { v4: uuidv4 } = require('uuid') 18 | const { readFile } = require('fs-extra') 19 | 20 | // load .env values in the e2e folder, if any 21 | require('dotenv').config({ path: path.join(__dirname, '.env') }) 22 | 23 | /** 24 | * @type {import('../src/index').PhotoshopAPI} 25 | */ 26 | let sdkClient = {} 27 | let container = {} 28 | let testRunId = '' 29 | const orgId = process.env.PHOTOSHOP_ORG_ID 30 | const apiKey = process.env.PHOTOSHOP_API_KEY 31 | const accessToken = process.env.PHOTOSHOP_ACCESS_TOKEN 32 | 33 | beforeAll(async () => { 34 | container = await storage.init() 35 | sdkClient = await sdk.init(orgId, apiKey, accessToken, container) 36 | testRunId = uuidv4() 37 | console.error(`Test run id: ${testRunId}`) 38 | }) 39 | 40 | test('sdk init test', async () => { 41 | expect(sdkClient.orgId).toBe(orgId) 42 | expect(sdkClient.apiKey).toBe(apiKey) 43 | expect(sdkClient.accessToken).toBe(accessToken) 44 | }) 45 | 46 | test('createCutout-soft', async () => { 47 | const input = `${testRunId}/autoCutout-soft-input.jpg` 48 | const output = `${testRunId}/autoCutout-soft-output.jpg` 49 | await container.copy('./testfiles/Example.jpg', input, { localSrc: true }) 50 | const job = await sdkClient.createCutout(input, { 51 | href: output, 52 | mask: { 53 | format: 'soft' 54 | } 55 | }) 56 | expect(job.isDone()).toEqual(true) 57 | }) 58 | 59 | test('createMask-soft', async () => { 60 | const input = `${testRunId}/createMask-soft-input.jpg` 61 | const output = `${testRunId}/createMask-soft-output.jpg` 62 | await container.copy('./testfiles/Example.jpg', input, { localSrc: true }) 63 | const job = await sdkClient.createMask(input, { 64 | href: output, 65 | mask: { 66 | format: 'soft' 67 | } 68 | }) 69 | expect(job.isDone()).toEqual(true) 70 | }) 71 | 72 | test('straighten', async () => { 73 | const input = `${testRunId}/straighten-input.jpg` 74 | const output = `${testRunId}/straighten-output.dng` 75 | await container.copy('./testfiles/Example.jpg', input, { localSrc: true }) 76 | const job = await sdkClient.straighten(input, output) 77 | expect(job.isDone()).toEqual(true) 78 | }) 79 | 80 | test('autoTone', async () => { 81 | const input = `${testRunId}/autoTone-input.jpg` 82 | const outputs = [ 83 | `${testRunId}/autoTone-output.png`, 84 | `${testRunId}/autoTone-output.jpg` 85 | ] 86 | await container.copy('./testfiles/Example.jpg', input, { localSrc: true }) 87 | const job = await sdkClient.autoTone(input, outputs) 88 | expect(job.isDone()).toEqual(true) 89 | }) 90 | 91 | test('editPhoto', async () => { 92 | const input = `${testRunId}/editPhoto-input.jpg` 93 | const output = `${testRunId}/editPhoto-output.jpg` 94 | await container.copy('./testfiles/Example.jpg', input, { localSrc: true }) 95 | const job = await sdkClient.editPhoto(input, output, { 96 | Contrast: 100 97 | }) 98 | expect(job.isDone()).toEqual(true) 99 | }) 100 | 101 | test('applyPreset-AutoBW', async () => { 102 | const input = `${testRunId}/applyPreset-input.jpg` 103 | const preset = `${testRunId}/Auto-BW.xmp` 104 | const output = `${testRunId}/applyPreset-output.jpg` 105 | await container.copy('./testfiles/Example.jpg', input, { localSrc: true }) 106 | await container.copy('./testfiles/Auto-BW.xmp', preset, { localSrc: true }) 107 | const job = await sdkClient.applyPreset(input, preset, output) 108 | expect(job.isDone()).toEqual(true) 109 | }) 110 | 111 | test('applyPresetXmp-AutoBW', async () => { 112 | const input = `${testRunId}/applyPresetXmp-input.jpg` 113 | const output = `${testRunId}/applyPresetXmp-output.jpg` 114 | const xmp = await readFile('./testfiles/Auto-BW.xmp', 'utf8') 115 | await container.copy('./testfiles/Example.jpg', input, { localSrc: true }) 116 | const job = await sdkClient.applyPresetXmp(input, output, xmp) 117 | expect(job.isDone()).toEqual(true) 118 | }) 119 | 120 | test('createDocument', async () => { 121 | const input = `${testRunId}/createDocument-input.jpg` 122 | const output = `${testRunId}/createDocument-output.psd` 123 | await container.copy('./testfiles/Example.jpg', input, { localSrc: true }) 124 | const job = await sdkClient.createDocument(output, { 125 | document: { 126 | width: 802, 127 | height: 528, 128 | resolution: 96, 129 | fill: sdk.BackgroundFill.TRANSPARENT, 130 | mode: sdk.Colorspace.RGB 131 | }, 132 | layers: [{ 133 | type: sdk.LayerType.ADJUSTMENT_LAYER, 134 | adjustments: { 135 | brightnessContrast: { 136 | brightness: 150 137 | } 138 | } 139 | }, { 140 | type: sdk.LayerType.LAYER, 141 | input, 142 | name: 'example' 143 | }] 144 | }) 145 | expect(job.isDone()).toEqual(true) 146 | }) 147 | 148 | test('getDocumentManifest', async () => { 149 | const input = `${testRunId}/getDocumentManifest-input.psd` 150 | await container.copy('./testfiles/Layer Comps.psd', input, { localSrc: true }) 151 | const job = await sdkClient.getDocumentManifest(input) 152 | expect(job.outputs[0].layers.length).toEqual(3) 153 | expect(job.outputs[0].layers[0].type).toEqual(sdk.LayerType.LAYER_SECTION) 154 | expect(job.outputs[0].layers[0].name).toEqual('text') 155 | expect(job.outputs[0].layers[1].type).toEqual(sdk.LayerType.LAYER_SECTION) 156 | expect(job.outputs[0].layers[1].name).toEqual('votives') 157 | expect(job.outputs[0].layers[2].type).toEqual(sdk.LayerType.LAYER_SECTION) 158 | expect(job.outputs[0].layers[2].name).toEqual('different backgrounds') 159 | expect(job.outputs[0].document.width).toEqual(400) 160 | expect(job.outputs[0].document.height).toEqual(424) 161 | expect(job.isDone()).toEqual(true) 162 | }) 163 | 164 | test('modifyDocument', async () => { 165 | const input = `${testRunId}/modifyDocument-input.psd` 166 | const output = `${testRunId}/modifyDocument-output.psd` 167 | await container.copy('./testfiles/Sunflower.psd', input, { localSrc: true }) 168 | const job = await sdkClient.modifyDocument(input, output, { 169 | layers: [{ 170 | add: { 171 | insertTop: true 172 | }, 173 | type: sdk.LayerType.ADJUSTMENT_LAYER, 174 | adjustments: { 175 | brightnessContrast: { 176 | brightness: 150 177 | } 178 | } 179 | }] 180 | }) 181 | expect(job.isDone()).toEqual(true) 182 | }) 183 | 184 | test('createRendition-500px', async () => { 185 | const input = `${testRunId}/createRendition-input.jpg` 186 | const output = `${testRunId}/createRendition-output.jpg` 187 | await container.copy('./testfiles/Example.jpg', input, { localSrc: true }) 188 | const job = await sdkClient.createRendition(input, { 189 | href: output, 190 | width: 500 191 | }) 192 | expect(job.isDone()).toEqual(true) 193 | }) 194 | -------------------------------------------------------------------------------- /e2e/jest.config.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Adobe. All rights reserved. 3 | This file is licensed to you under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. You may obtain a copy 5 | of the License at http://www.apache.org/licenses/LICENSE-2.0 6 | Unless required by applicable law or agreed to in writing, software distributed under 7 | the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS 8 | OF ANY KIND, either express or implied. See the License for the specific language 9 | governing permissions and limitations under the License. 10 | */ 11 | 12 | 'use strict' 13 | 14 | module.exports = { 15 | testEnvironment: 'node', 16 | setupFilesAfterEnv: [ 17 | '../test/jest/jest.setup.js' 18 | ], 19 | testRegex: './e2e/e2e.*.js' 20 | } 21 | -------------------------------------------------------------------------------- /e2e/storage.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Adobe. All rights reserved. 3 | This file is licensed to you under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. You may obtain a copy 5 | of the License at http://www.apache.org/licenses/LICENSE-2.0 6 | Unless required by applicable law or agreed to in writing, software distributed under 7 | the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS 8 | OF ANY KIND, either express or implied. See the License for the specific language 9 | governing permissions and limitations under the License. 10 | */ 11 | 12 | 'use strict' 13 | 14 | const { 15 | BlobServiceClient, 16 | StorageSharedKeyCredential, 17 | generateBlobSASQueryParameters, 18 | SASProtocol, 19 | BlobSASPermissions 20 | } = require('@azure/storage-blob') 21 | 22 | /** 23 | * Abstraction around an Azure container that follows the aio-lib-files interface. 24 | * This is intended to be replaced when aio-lib-files can be used again in a local environment. 25 | */ 26 | class AzureContainer { 27 | constructor (containerClient, sharedKeyCredential) { 28 | this.containerClient = containerClient 29 | this.sharedKeyCredential = sharedKeyCredential 30 | } 31 | 32 | /** 33 | * Copy a blob to/from the container 34 | * 35 | * @param {string} srcPath Source path 36 | * @param {string} destPath Destination path 37 | * @param {object} [options] Copy options 38 | * @param {boolean} [options.localSrc=false] Source path is local on disk 39 | * @param {boolean} [options.localDest=false] Destination path is local on disk 40 | */ 41 | async copy (srcPath, destPath, options) { 42 | const localSrc = options && options.localSrc 43 | const localDest = options && options.localDest 44 | if (localSrc && !localDest) { 45 | const destClient = this.containerClient.getBlockBlobClient(destPath) 46 | await destClient.uploadFile(srcPath) 47 | } else if (!localSrc && localDest) { 48 | const srcClient = this.containerClient.getBlockBlobClient(srcPath) 49 | await srcClient.downloadToFile(destPath) 50 | } else if (!localSrc && !localDest) { 51 | const srcClient = this.containerClient.getBlockBlobClient(srcPath) 52 | const destClient = this.containerClient.getBlockBlobClient(destPath) 53 | const copyPoller = destClient.beginCopyFromURL(srcClient.url) 54 | await copyPoller.pollUntilDone() 55 | } else { 56 | throw Error(`Local copy not supported: ${srcPath} ${destPath}`) 57 | } 58 | } 59 | 60 | /** 61 | * Generate a presigned url 62 | * 63 | * @param {string} blobName Name of the blob 64 | * @param {object} options Presign options 65 | * @param {string} [options.permissions='r'] Permission of the URL 66 | * @param {number} options.expiryInSeconds Expiration time in seconds 67 | * @returns {string} presigned url 68 | */ 69 | async generatePresignURL (blobName, options) { 70 | const ttl = options.expiryInSeconds * 1000 71 | const perm = options.permissions || 'r' 72 | 73 | const permissions = new BlobSASPermissions() 74 | permissions.read = (perm.indexOf('r') >= 0) 75 | permissions.write = (perm.indexOf('w') >= 0) 76 | permissions.delete = (perm.indexOf('d') >= 0) 77 | 78 | const blobClient = this.containerClient.getBlockBlobClient(blobName) 79 | const query = generateBlobSASQueryParameters({ 80 | protocol: SASProtocol.Https, 81 | expiresOn: new Date(Date.now() + ttl), 82 | containerName: this.containerClient.containerName, 83 | blobName, 84 | permissions 85 | }, this.sharedKeyCredential).toString() 86 | 87 | return `${blobClient.url}?${query}` 88 | } 89 | } 90 | 91 | /** 92 | * Initialize storage for e2e testing 93 | * 94 | * @returns {AzureContainer} Storage container 95 | */ 96 | async function init () { 97 | const accountKey = process.env.PHOTOSHOP_AZURE_STORAGE_KEY 98 | const accountName = process.env.PHOTOSHOP_AZURE_STORAGE_ACCOUNT 99 | const containerName = process.env.PHOTOSHOP_AZURE_STORAGE_CONTAINER 100 | 101 | const sharedKeyCredential = new StorageSharedKeyCredential(accountName, accountKey) 102 | const blobServiceClient = new BlobServiceClient( 103 | `https://${accountName}.blob.core.windows.net`, 104 | sharedKeyCredential 105 | ) 106 | 107 | const containerClient = blobServiceClient.getContainerClient(containerName) 108 | return new AzureContainer(containerClient, sharedKeyCredential) 109 | } 110 | 111 | module.exports = { 112 | init 113 | } 114 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@adobe/aio-lib-photoshop-api", 3 | "version": "1.1.0", 4 | "description": "Adobe Photoshop API Library", 5 | "author": "Adobe Inc.", 6 | "homepage": "https://github.com/AdobeDocs/photoshop-api-docs", 7 | "repository": "https://github.com/adobe/aio-lib-photoshop-api", 8 | "bugs": { 9 | "url": "https://github.com/adobe/aio-lib-photoshop-api/issues" 10 | }, 11 | "license": "Apache-2.0", 12 | "main": "src/index.js", 13 | "private": false, 14 | "bundleDependencies": [], 15 | "deprecated": false, 16 | "scripts": { 17 | "e2e": "jest --config e2e/jest.config.js", 18 | "generate-docs": "npm run typings && npm run jsdoc", 19 | "jsdoc": "jsdoc2md -t ./docs/readme_template.md src/**/*.js > README.md", 20 | "lint": "eslint src test e2e", 21 | "test": "npm run validate && npm run lint && npm run unit-tests", 22 | "typings": "jsdoc -t node_modules/tsd-jsdoc/dist -r src/*.js -d .", 23 | "unit-tests": "jest --config test/jest.config.js --maxWorkers=2", 24 | "validate": "node bin/validate_spec.js spec/api.json", 25 | "redoc-serve": "redoc-cli serve spec/api.json", 26 | "redoc-bundle": "redoc-cli bundle spec/api.json -o docs/index.html", 27 | "semantic-release": "semantic-release" 28 | }, 29 | "engines": { 30 | "node": ">=10.0.0" 31 | }, 32 | "dependencies": { 33 | "@adobe/aio-lib-core-errors": "^3.0.0", 34 | "@adobe/aio-lib-core-logging": "1.0.0", 35 | "@adobe/aio-lib-core-networking": "^1.0.1", 36 | "@adobe/node-fetch-retry": "^2.0.0", 37 | "cross-fetch": "^3.0.4", 38 | "swagger-client": "3.9.6", 39 | "valid-url": "^1.0.9" 40 | }, 41 | "files": [ 42 | "src", 43 | "spec", 44 | "README.md", 45 | "LICENSE", 46 | "COPYRIGHT" 47 | ], 48 | "devDependencies": { 49 | "@adobe/eslint-config-aio-lib-config": "^1.3.0", 50 | "@azure/storage-blob": "^12.2.1", 51 | "@semantic-release/git": "^9.0.1", 52 | "@types/node-fetch": "^2.5.4", 53 | "babel-runtime": "^6.26.0", 54 | "codecov": "^3.5.0", 55 | "conventional-changelog-eslint": "^3.0.9", 56 | "dotenv": "^8.1.0", 57 | "eol": "^0.9.1", 58 | "eslint": "^6.2.2", 59 | "eslint-config-standard": "^14.1.1", 60 | "eslint-plugin-import": "^2.22.0", 61 | "eslint-plugin-jest": "^23.20.0", 62 | "eslint-plugin-jsdoc": "^25.0.1", 63 | "eslint-plugin-node": "^10.0.0", 64 | "eslint-plugin-notice": "^0.9.10", 65 | "eslint-plugin-promise": "^4.2.1", 66 | "eslint-plugin-standard": "^4.0.1", 67 | "fs-extra": "^9.0.1", 68 | "glob": "^7.1.6", 69 | "jest": "^24.8.0", 70 | "jest-fetch-mock": "^3.0.2", 71 | "jest-junit": "^10.0.0", 72 | "jest-plugin-fs": "^2.9.0", 73 | "jsdoc": "^3.6.3", 74 | "jsdoc-to-markdown": "^5.0.0", 75 | "openapi-schema-validator": "^3.0.3", 76 | "redoc-cli": "^0.9.12", 77 | "semantic-release": "^17.4.7", 78 | "stdout-stderr": "^0.1.9", 79 | "tsd-jsdoc": "^2.4.0", 80 | "uuid": "^8.3.0" 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/SDKErrors.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Adobe. All rights reserved. 3 | This file is licensed to you under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. You may obtain a copy 5 | of the License at http://www.apache.org/licenses/LICENSE-2.0 6 | Unless required by applicable law or agreed to in writing, software distributed under 7 | the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS 8 | OF ANY KIND, either express or implied. See the License for the specific language 9 | governing permissions and limitations under the License. 10 | */ 11 | 12 | 'use strict' 13 | 14 | const { ErrorWrapper, createUpdater } = require('@adobe/aio-lib-core-errors').AioCoreSDKErrorWrapper 15 | 16 | const codes = {} 17 | const messages = new Map() 18 | 19 | /** 20 | * Create an Updater for the Error wrapper 21 | * 22 | * @ignore 23 | */ 24 | const Updater = createUpdater( 25 | // object that stores the error classes (to be exported) 26 | codes, 27 | // Map that stores the error strings (to be exported) 28 | messages 29 | ) 30 | 31 | /** 32 | * Provides a wrapper to easily create classes of a certain name, and values 33 | * 34 | * @ignore 35 | */ 36 | const E = ErrorWrapper( 37 | // The class name for your SDK Error. Your Error objects will be these objects 38 | 'PhotoshopSDKError', 39 | // The name of your SDK. This will be a property in your Error objects 40 | 'PhotoshopSDK', 41 | // the object returned from the CreateUpdater call above 42 | Updater 43 | // the base class that your Error class is extending. AioCoreSDKError is the default 44 | /* AioCoreSDKError, */ 45 | ) 46 | 47 | module.exports = { 48 | codes, 49 | messages 50 | } 51 | 52 | // Define your error codes with the wrapper 53 | E('ERROR_SDK_INITIALIZATION', 'SDK initialization error(s). Missing arguments: %s') 54 | E('ERROR_STATUS_URL_MISSING', 'Status URL is missing in the response: %s') 55 | E('ERROR_INPUT_VALIDATION', '%s') 56 | E('ERROR_PAYLOAD_VALIDATION', '%s') 57 | E('ERROR_REQUEST_BODY', '%s') 58 | E('ERROR_BAD_REQUEST', '%s') 59 | E('ERROR_UNAUTHORIZED', '%s') 60 | E('ERROR_AUTH_FORBIDDEN', '%s') 61 | E('ERROR_FILE_EXISTS', '%s') 62 | E('ERROR_INPUT_FILE_EXISTS', '%s') 63 | E('ERROR_RESOURCE_NOT_FOUND', '%s') 64 | E('ERROR_INVALID_CONTENT_TYPE', '%s') 65 | E('ERROR_TOO_MANY_REQUESTS', 'Requests are exceeding allowed rate, connection refused: %s') 66 | E('ERROR_UNDEFINED', '%s') 67 | E('ERROR_UNKNOWN', 'Unknown Error: %s') 68 | -------------------------------------------------------------------------------- /src/fileresolver.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Adobe. All rights reserved. 3 | This file is licensed to you under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. You may obtain a copy 5 | of the License at http://www.apache.org/licenses/LICENSE-2.0 6 | Unless required by applicable law or agreed to in writing, software distributed under 7 | the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS 8 | OF ANY KIND, either express or implied. See the License for the specific language 9 | governing permissions and limitations under the License. 10 | */ 11 | 12 | 'use strict' 13 | const path = require('path') 14 | const validUrl = require('valid-url') 15 | const { Storage, MimeType } = require('./types') 16 | require('./types') 17 | 18 | /* global File Input Output CreateDocumentOptions ModifyDocumentOptions ReplaceSmartObjectOptions ApplyPhotoshopActionsOptions */ 19 | 20 | const ExtensionMimeTypeMap = { 21 | '.dng': MimeType.DNG, 22 | '.jpg': MimeType.JPEG, 23 | '.jpeg': MimeType.JPEG, 24 | '.png': MimeType.PNG, 25 | '.psb': MimeType.PSD, 26 | '.psd': MimeType.PSD, 27 | '.tif': MimeType.TIFF, 28 | '.tiff': MimeType.TIFF 29 | } 30 | 31 | /** 32 | * Determine the desired format type based on the extension. 33 | * Defaults to `image/png` if the type can't be determined. 34 | * 35 | * @private 36 | * @param {Output} output Output file 37 | * @returns {Output} detected mime type 38 | */ 39 | function resolveMimeType (output) { 40 | if (!output.type) { 41 | let pathname = output.href 42 | if (validUrl.isWebUri(output.href)) { 43 | ({ pathname } = new URL(output.href)) 44 | pathname = decodeURIComponent(pathname) 45 | } 46 | output.type = ExtensionMimeTypeMap[path.extname(pathname)] || MimeType.PNG 47 | } 48 | return output 49 | } 50 | 51 | /** 52 | * Determine the proper storage type based on the hostname of a URL 53 | * 54 | * @private 55 | * @param {string} href https url to check 56 | * @returns {Storage} Auto-detected storage 57 | */ 58 | function getStorageFromUrl (href) { 59 | const { hostname } = new URL(href) 60 | if (hostname.endsWith('.blob.core.windows.net') || hostname.endsWith('.azureedge.net')) { 61 | return Storage.AZURE 62 | } else if (hostname === 'content.dropboxapi.com') { 63 | return Storage.DROPBOX 64 | } else { 65 | return Storage.EXTERNAL 66 | } 67 | } 68 | 69 | /** 70 | * @typedef {object} FileResolverOptions 71 | * @description File resolver options 72 | * @property {number} [presignExpiryInSeconds=3600] Expiry time of any presigned urls, defaults to 1 hour 73 | * @property {boolean} [defaultAdobeCloudPaths] True if paths should be considered references to files in Creative Cloud 74 | */ 75 | 76 | /** 77 | * Resolves the storage and mime type of files referenced in the API. 78 | * 79 | * The storage type storage type is resolved for input and output files using the following heuristic: 80 | * 81 | * - If the storage type is provided, it is used as-is 82 | * - If a URL is provided, the hostname is inspected to determine Azure, Dropbox, or External (default) 83 | * - If a path is provided, the path resolved to Adobe I/O Files if an instance is provided to the constructor, otherwise it's Creative Cloud 84 | * 85 | * Path resolution can be overridden by the `defaultAdobeCloudPaths` option. 86 | * 87 | * The mime-type is resolved based on the extension of the pathname of the URL or the path. If no extension can 88 | * be found or the extension is unknown, the default `image/png` is selected. 89 | */ 90 | class FileResolver { 91 | /** 92 | * Construct a file resolver 93 | * 94 | * @param {*} [files] Adobe I/O Files instance 95 | * @param {FileResolverOptions} [options] Options 96 | */ 97 | constructor (files, options) { 98 | /** 99 | * Adobe I/O Files instance 100 | * 101 | * @private 102 | */ 103 | this.files = files 104 | 105 | /** 106 | * Expiry time of presigned urls in seconds 107 | * 108 | * @type {number} 109 | */ 110 | this.presignExpiryInSeconds = (options && options.presignExpiryInSeconds) || 3600 111 | 112 | /** 113 | * Plain paths can reference either Adobe Creative Cloud or Adobe I/O Files. 114 | * 115 | * If an instance of files is provided, the default is considered to be 116 | * Adobe I/O Files, otherwise it's Creative Cloud. The default can be overridden 117 | * using the options 118 | * 119 | * @type {Storage} 120 | */ 121 | this.defaultPathStorage = files ? Storage.AIO : Storage.ADOBE 122 | if (options && options.defaultAdobeCloudPaths) { 123 | this.defaultPathStorage = Storage.ADOBE 124 | } 125 | } 126 | 127 | /** 128 | * Auto-detect the storage associated with the href 129 | * 130 | * @private 131 | * @param {Input|Output} file Input or output reference 132 | * @param {'r'|'rwd'} permissions Permissions required for file 133 | * @returns {Input|Output} Resolved input or output reference with storage 134 | */ 135 | async __resolveStorage (file, permissions) { 136 | if (validUrl.isWebUri(file.href)) { 137 | return Object.assign({}, file, { 138 | storage: getStorageFromUrl(file.href) 139 | }) 140 | } else if (this.defaultPathStorage === Storage.AIO) { 141 | const href = await this.files.generatePresignURL(file.href, { 142 | permissions, 143 | expiryInSeconds: this.presignExpiryInSeconds 144 | }) 145 | return Object.assign({}, file, { 146 | href, 147 | storage: getStorageFromUrl(href) 148 | }) 149 | } else { 150 | return Object.assign({}, file, { 151 | storage: this.defaultPathStorage 152 | }) 153 | } 154 | } 155 | 156 | /** 157 | * Resolve file from href to an object with href and storage 158 | * 159 | * @private 160 | * @param {string|Input|Output} file Input, output, or href to resolve 161 | * @param {'r'|'rwd'} permissions Permissions required for file 162 | * @returns {Input|Output} resolved input or output with storage 163 | */ 164 | async __resolveFile (file, permissions) { 165 | if (!file) { 166 | throw Error('No file provided') 167 | } else if (typeof file === 'string') { 168 | return this.__resolveStorage({ 169 | href: file 170 | }, permissions) 171 | } else if (!file.href) { 172 | throw Error(`Missing href: ${JSON.stringify(file)}`) 173 | } else if (!file.storage) { 174 | return this.__resolveStorage(file, permissions) 175 | } else { 176 | return file 177 | } 178 | } 179 | 180 | /** 181 | * Resolve files from href to an object with href and storage 182 | * 183 | * @private 184 | * @param {string|string[]|Input|Input[]|Output|Output[]} files One or more files 185 | * @param {'r'|'rwd'} permissions Permissions required for file 186 | * @returns {Input[]|Output[]} resolved files 187 | */ 188 | async __resolveFiles (files, permissions) { 189 | if (Array.isArray(files)) { 190 | return Promise.all(files.map(file => this.__resolveFile(file, permissions))) 191 | } else { 192 | const resolvedFile = await this.__resolveFile(files, permissions) 193 | return [resolvedFile] 194 | } 195 | } 196 | 197 | /** 198 | * Resolve input file from href to an object with href and storage 199 | * 200 | * @param {string|Input} input Input or href to resolve 201 | * @returns {Input} resolved input 202 | */ 203 | async resolveInput (input) { 204 | return this.__resolveFile(input, 'r') 205 | } 206 | 207 | /** 208 | * Resolve input files from hrefs to an array of objects with href and storage 209 | * 210 | * @param {string|string[]|Input|Input[]} inputs One or more files 211 | * @returns {Input[]} resolved files 212 | */ 213 | async resolveInputs (inputs) { 214 | return this.__resolveFiles(inputs, 'r') 215 | } 216 | 217 | /** 218 | * Resolve the font and layer inputs in the document options 219 | * 220 | * @param {CreateDocumentOptions|ModifyDocumentOptions|ReplaceSmartObjectOptions} options Document options 221 | * @returns {CreateDocumentOptions|ModifyDocumentOptions|ReplaceSmartObjectOptions} Document options 222 | */ 223 | async resolveInputsDocumentOptions (options) { 224 | if (options && options.fonts) { 225 | options.fonts = await this.resolveInputs(options.fonts) 226 | } 227 | if (options && options.layers) { 228 | options.layers = await Promise.all(options.layers.map(async layer => { 229 | if (layer.input) { 230 | layer.input = await this.resolveInput(layer.input) 231 | } 232 | return layer 233 | })) 234 | } 235 | return options 236 | } 237 | 238 | /** 239 | * Resolve the actions, fonts, and custom presets options 240 | * 241 | * @param {ApplyPhotoshopActionsOptions} options Photoshop Actions options 242 | * @returns {ApplyPhotoshopActionsOptions} Photoshop Actions options 243 | */ 244 | async resolveInputsPhotoshopActionsOptions (options) { 245 | if (options && options.actions) { 246 | options.actions = await this.resolveInputs(options.actions) 247 | } 248 | if (options && options.fonts) { 249 | options.fonts = await this.resolveInputs(options.fonts) 250 | } 251 | if (options && options.patterns) { 252 | options.patterns = await this.resolveInputs(options.patterns) 253 | } 254 | if (options && options.brushes) { 255 | options.brushes = await this.resolveInputs(options.brushes) 256 | } 257 | if (options && options.additionalImages) { 258 | options.additionalImages = await this.resolveInputs(options.additionalImages) 259 | } 260 | return options 261 | } 262 | 263 | /** 264 | * Resolve output from href to an object with href, storage, and type 265 | * 266 | * @param {string|File|Output} output One or more output files 267 | * @returns {Output} resolved files 268 | */ 269 | async resolveOutput (output) { 270 | return resolveMimeType( 271 | await this.__resolveFile(output, 'rwd') 272 | ) 273 | } 274 | 275 | /** 276 | * Resolve outputs from href to an object with href, storage, and type 277 | * 278 | * @param {string|string[]|File|File[]|Output|Output[]} outputs One or more output files 279 | * @returns {Output[]} resolved files 280 | */ 281 | async resolveOutputs (outputs) { 282 | const resolvedFiles = await this.__resolveFiles(outputs, 'rwd') 283 | return resolvedFiles.map(output => resolveMimeType(output)) 284 | } 285 | } 286 | 287 | module.exports = { 288 | FileResolver 289 | } 290 | -------------------------------------------------------------------------------- /src/helpers.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Adobe. All rights reserved. 3 | This file is licensed to you under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. You may obtain a copy 5 | of the License at http://www.apache.org/licenses/LICENSE-2.0 6 | Unless required by applicable law or agreed to in writing, software distributed under 7 | the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS 8 | OF ANY KIND, either express or implied. See the License for the specific language 9 | governing permissions and limitations under the License. 10 | */ 11 | 12 | 'use strict' 13 | 14 | const loggerNamespace = 'aio-lib-photoshop-api' 15 | const logger = require('@adobe/aio-lib-core-logging')(loggerNamespace, { level: process.env.LOG_LEVEL }) 16 | const NodeFetchRetry = require('@adobe/node-fetch-retry') 17 | 18 | // Wait 1 second for first retry, then 2, 4, etc 19 | const RETRY_INITIAL_DELAY = 1000 20 | 21 | // Retry for up to 14 seconds 22 | const RETRY_MAX_DURATON = 14000 23 | 24 | /** 25 | * Reduce an Error to a string 26 | * 27 | * @private 28 | * @param {Error} error the Error object to reduce 29 | * @returns {string} string reduced from an Error 30 | */ 31 | function reduceError (error = {}) { 32 | const response = error.response 33 | if (response) { 34 | if (response.status && response.statusText && response.body) { 35 | return `${response.status} - ${response.statusText} (${JSON.stringify(response.body)})` 36 | } 37 | } 38 | 39 | return error 40 | } 41 | 42 | /** 43 | * Determine if we should retry fetch due to Server errors (server busy or other application errors) 44 | * 45 | * @param {*} response Fetch response object, should at least have a status property which is the HTTP status code received 46 | * @returns {boolean} true if we should retry or false if not 47 | */ 48 | function shouldRetryFetch (response = {}) { 49 | return (response.status >= 500) || (response.status === 429) 50 | } 51 | 52 | /** 53 | * Fetch a URL, with retry options provided or default retry options otherwise 54 | * By default retries will happen for 14 seconds (3 retries at 1, 2 and then 4 seconds -- there cannot be enough time for anotehr retry after that) 55 | * Retry will occur if error code 429 or >= 500 occurs. 56 | * 57 | * @param {*} options Fetch options object, which can also include retryOptions described here https://github.com/adobe/node-fetch-retry 58 | * @returns {Function} Wrapped node fetch retry function which takes our preferred default options 59 | */ 60 | function nodeFetchRetry (options = {}) { 61 | const retryOptions = 'retryOptions' in options ? options.retryOptions : {} 62 | options.retryOptions = { 63 | retryInitialDelay: RETRY_INITIAL_DELAY, 64 | retryMaxDuration: RETRY_MAX_DURATON, 65 | retryOnHttpResponse: shouldRetryFetch, 66 | ...retryOptions 67 | } 68 | const fetchFunction = (url, opts) => NodeFetchRetry(url, { ...options, ...opts }) 69 | // This is helpful for unit testing purposes 70 | fetchFunction.isNodeFetchRetry = true 71 | return fetchFunction 72 | } 73 | 74 | /** 75 | * Parse through options object and determine correct parameters to Swagger for desired fetch approach 76 | * 77 | * @param {*} options Photoshop API options object 78 | * @returns {*} Swagger options object with relevant settings for fetch module 79 | */ 80 | function getFetchOptions (options) { 81 | if (options !== undefined && options.useSwaggerFetch) { // << TEST 82 | logger.debug('Using swagger fetch') 83 | return {} 84 | } else if (options !== undefined && options.userFetch !== undefined) { // << TEST 85 | logger.debug('Using custom fetch') 86 | return { userFetch: options.userFetch } 87 | } else { 88 | logger.debug('Using node-fetch-retry') 89 | return { userFetch: nodeFetchRetry(options) } 90 | } 91 | } 92 | 93 | /** 94 | * Create request options for openapi client 95 | * 96 | * @private 97 | * @param {object} parameters object 98 | * @returns {object} options request options 99 | */ 100 | function createRequestOptions ({ apiKey, accessToken, body = {} }) { 101 | return { 102 | requestBody: body, 103 | securities: { 104 | authorized: { 105 | BearerAuth: { value: accessToken }, 106 | ApiKeyAuth: { value: apiKey } 107 | } 108 | } 109 | } 110 | } 111 | 112 | /** 113 | * Converts a fetch Response object's body contents to a string. 114 | * 115 | * @private 116 | * @param {Response} response the response object 117 | * @returns {Promise} a Promise that resolves to the converted object's body contents 118 | */ 119 | async function responseBodyToString (response) { 120 | try { 121 | // work with differences in the Response object processed by swagger vs straight fetch 122 | if (typeof response.text === 'function') { 123 | const _res = response.clone() // work around 'body already consumed' issues 124 | return _res.text() 125 | } else { 126 | return response.text 127 | } 128 | } catch (error) { 129 | return Promise.reject(error.toString()) 130 | } 131 | } 132 | 133 | /** 134 | * Filters a json object, removing any undefined or null entries. 135 | * Returns a new object (does not mutate original) 136 | * 137 | * @private 138 | * @param {object} json the json object to filter 139 | * @returns {object} the filtered object (a new object) 140 | */ 141 | function filterUndefinedOrNull (json) { 142 | return Object.entries(json).reduce((accum, [key, value]) => { 143 | if (value == null) { // undefined or null 144 | return accum 145 | } else { 146 | return { ...accum, [key]: value } 147 | } 148 | }, {}) 149 | } 150 | 151 | /** 152 | * Converts a fetch Request object to a string. 153 | * 154 | * @private 155 | * @param {Request} request the request object 156 | * @returns {object} the converted object 157 | */ 158 | function requestToString (request) { 159 | try { 160 | const { method, headers, url, credentials, body } = request 161 | const json = { method, headers, url, credentials, body } 162 | 163 | // work with differences in the Request object processed by swagger vs straight fetch 164 | if (request.headers && request.headers.forEach && typeof request.headers.forEach === 'function') { 165 | json.headers = {} 166 | request.headers.forEach((value, key) => { 167 | json.headers[key] = value 168 | }) 169 | } 170 | 171 | return JSON.stringify(filterUndefinedOrNull(json), null, 2) 172 | } catch (error) { 173 | return error.toString() 174 | } 175 | } 176 | 177 | /** 178 | * A request interceptor that logs the request 179 | * 180 | * @private 181 | * @param {Response} response the response object 182 | * @returns {Response} the response object 183 | */ 184 | async function responseInterceptor (response) { 185 | logger.debug(`RESPONSE:\n ${await responseBodyToString(response)}`) 186 | return response 187 | } 188 | 189 | module.exports = { 190 | responseBodyToString, 191 | requestToString, 192 | createRequestOptions, 193 | responseInterceptor, 194 | nodeFetchRetry, 195 | shouldRetryFetch, 196 | getFetchOptions, 197 | reduceError 198 | } 199 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Adobe. All rights reserved. 3 | This file is licensed to you under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. You may obtain a copy 5 | of the License at http://www.apache.org/licenses/LICENSE-2.0 6 | Unless required by applicable law or agreed to in writing, software distributed under 7 | the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS 8 | OF ANY KIND, either express or implied. See the License for the specific language 9 | governing permissions and limitations under the License. 10 | */ 11 | 12 | 'use strict' 13 | 14 | const Swagger = require('swagger-client') 15 | const loggerNamespace = 'aio-lib-photoshop-api' 16 | const logger = require('@adobe/aio-lib-core-logging')(loggerNamespace, { level: process.env.LOG_LEVEL }) 17 | const { reduceError, responseInterceptor, createRequestOptions, getFetchOptions, requestToString } = require('./helpers') 18 | const { codes } = require('./SDKErrors') 19 | const { Job } = require('./job') 20 | const { FileResolver } = require('./fileresolver') 21 | const types = require('./types') 22 | require('./types') 23 | 24 | const { description, version } = require('../package.json') 25 | const defaultUserAgentHeader = `${description}/${version}` 26 | 27 | /* global EditPhotoOptions Input Output CreateDocumentOptions MimeType ModifyDocumentOptions ReplaceSmartObjectOptions ApplyPhotoshopActionsOptions */ 28 | 29 | /** 30 | * Returns a Promise that resolves with a new PhotoshopAPI object. 31 | * 32 | * @param {string} orgId IMS organization id 33 | * @param {string} apiKey the API key for your integration 34 | * @param {string} accessToken the access token for your integration 35 | * @param {*} [files] Adobe I/O Files instance 36 | * @param {PhotoshopAPIOptions} [options] Options 37 | * @returns {Promise} a Promise with a PhotoshopAPI object 38 | */ 39 | async function init (orgId, apiKey, accessToken, files, options) { 40 | try { 41 | const clientWrapper = new PhotoshopAPI() 42 | const initializedSDK = await clientWrapper.init(orgId, apiKey, accessToken, files, options) 43 | logger.debug('sdk initialized successfully') 44 | return initializedSDK 45 | } catch (err) { 46 | logger.debug(`sdk init error: ${err}`) 47 | throw err 48 | } 49 | } 50 | 51 | /** 52 | * Translate and throw error 53 | * 54 | * @private 55 | * @param {*} err Error response 56 | */ 57 | function throwError (err) { 58 | const errType = err.response && err.response.body && err.response.body.type 59 | switch (err.status) { 60 | case 400: 61 | switch (errType) { 62 | case 'InputValidationError': 63 | throw new codes.ERROR_INPUT_VALIDATION({ messageValues: reduceError(err) }) 64 | case 'PayloadValidationError': 65 | throw new codes.ERROR_PAYLOAD_VALIDATION({ messageValues: reduceError(err) }) 66 | case 'RequestBodyError': 67 | throw new codes.ERROR_REQUEST_BODY({ messageValues: reduceError(err) }) 68 | default: 69 | throw new codes.ERROR_BAD_REQUEST({ messageValues: reduceError(err) }) 70 | } 71 | case 401: 72 | throw new codes.ERROR_UNAUTHORIZED({ messageValues: reduceError(err) }) 73 | case 403: 74 | throw new codes.ERROR_AUTH_FORBIDDEN({ messageValues: reduceError(err) }) 75 | case 404: 76 | switch (errType) { 77 | case 'FileExistsErrors': 78 | throw new codes.ERROR_FILE_EXISTS({ messageValues: reduceError(err) }) 79 | case 'InputFileExistsErrors': 80 | throw new codes.ERROR_INPUT_FILE_EXISTS({ messageValues: reduceError(err) }) 81 | default: 82 | throw new codes.ERROR_RESOURCE_NOT_FOUND({ messageValues: reduceError(err) }) 83 | } 84 | case 415: 85 | throw new codes.ERROR_INVALID_CONTENT_TYPE({ messageValues: reduceError(err) }) 86 | case 429: 87 | throw new codes.ERROR_TOO_MANY_REQUESTS({ messageValues: reduceError(err) }) 88 | case 500: 89 | throw new codes.ERROR_UNDEFINED({ messageValues: reduceError(err) }) 90 | default: 91 | throw new codes.ERROR_UNKNOWN({ messageValues: reduceError(err) }) 92 | } 93 | } 94 | 95 | /** 96 | * @typedef {object} PhotoshopAPIOptions 97 | * @description Photoshop API options 98 | * @property {number} [presignExpiryInSeconds=3600] Expiry time of any presigned urls, defaults to 1 hour 99 | * @property {boolean} [defaultAdobeCloudPaths] True if paths should be considered references to files in Creative Cloud 100 | * @property {boolean} [useSwaggerFetch=false] True if Swagger's fetch implementation should be used, otherwise will use userFetch if provided or @adobe/node-fetch-retry if nothing else. 101 | * @property {Function} [userFetch] Fetch function to use replacing Swagger's fetch and node-fetch-retry. Useful for mocking, etc 102 | */ 103 | 104 | /** 105 | * This class provides methods to call your PhotoshopAPI APIs. 106 | * Before calling any method initialize the instance by calling the `init` method on it 107 | * with valid values for orgId, apiKey and accessToken 108 | */ 109 | class PhotoshopAPI { 110 | /** 111 | * Initializes the PhotoshopAPI object and returns it. 112 | * 113 | * @param {string} orgId the IMS organization id 114 | * @param {string} apiKey the API key for your integration 115 | * @param {string} accessToken the access token for your integration 116 | * @param {*} [files] Adobe I/O Files instance 117 | * @param {PhotoshopAPIOptions} [options] Options 118 | * @returns {Promise} a PhotoshopAPI object 119 | */ 120 | async init (orgId, apiKey, accessToken, files, options) { 121 | // init swagger client 122 | const spec = require('../spec/api.json') 123 | 124 | const requestInterceptor = request => { 125 | return this.requestInterceptor(request) 126 | } 127 | 128 | const swaggerOptions = { 129 | spec: spec, 130 | requestInterceptor, 131 | responseInterceptor, 132 | authorizations: { 133 | BearerAuth: { value: accessToken }, 134 | ApiKeyAuth: { value: apiKey } 135 | }, 136 | usePromise: true, 137 | ...getFetchOptions(options) 138 | } 139 | 140 | this.sdk = await new Swagger(swaggerOptions) 141 | 142 | this.userAgentHeader = (options && options['User-Agent']) || defaultUserAgentHeader 143 | 144 | const initErrors = [] 145 | if (!apiKey) { 146 | initErrors.push('apiKey') 147 | } 148 | if (!accessToken) { 149 | initErrors.push('accessToken') 150 | } 151 | 152 | if (initErrors.length) { 153 | const sdkDetails = { orgId, apiKey, accessToken } 154 | throw new codes.ERROR_SDK_INITIALIZATION({ sdkDetails, messageValues: `${initErrors.join(', ')}` }) 155 | } 156 | 157 | /** 158 | * The IMS organization id 159 | * 160 | * @type {string} 161 | */ 162 | this.orgId = orgId 163 | 164 | /** 165 | * The api key from your integration 166 | * 167 | * @type {string} 168 | */ 169 | this.apiKey = apiKey 170 | 171 | /** 172 | * The access token from your integration 173 | * 174 | * @type {string} 175 | */ 176 | this.accessToken = accessToken 177 | 178 | /** 179 | * @private 180 | */ 181 | this.fileResolver = new FileResolver(files, options) 182 | 183 | return this 184 | } 185 | 186 | __createRequestOptions (body = {}) { 187 | return createRequestOptions({ 188 | orgId: this.orgId, 189 | apiKey: this.apiKey, 190 | accessToken: this.accessToken, 191 | body 192 | }) 193 | } 194 | 195 | /** 196 | * A request interceptor that updates User-Agent header and logs the request 197 | * 198 | * @private 199 | * @param {Request} request the request object 200 | * @returns {Request} the request object 201 | */ 202 | requestInterceptor (request) { 203 | if (!request.headers) { 204 | request.headers = {} 205 | } 206 | 207 | request.headers['User-Agent'] = this.userAgentHeader 208 | logger.debug(`REQUEST:\n ${requestToString(request)}`) 209 | return request 210 | } 211 | 212 | /** 213 | * Acquire the current job status 214 | * 215 | * The APIs for status updates are defined in the OpenAPI spec, however the status is provided 216 | * as a url, and not just a jobId. Instead of parsing the url to extract the jobId, this code is 217 | * invoking the url directly but routed through the Swagger client to take advantage of the request 218 | * and response interceptor for consistency. 219 | * 220 | * @private 221 | * @param {string} url Job status url 222 | * @returns {*} Job status response 223 | */ 224 | async __getJobStatus (url) { 225 | const requestInterceptor = request => { 226 | return this.requestInterceptor(request) 227 | } 228 | 229 | const response = await Swagger.http({ 230 | url, 231 | headers: { 232 | authorization: `Bearer ${this.accessToken}`, 233 | 'x-api-key': this.apiKey, 234 | 'x-gw-ims-org-id': this.orgId 235 | }, 236 | method: 'GET', 237 | requestInterceptor, 238 | responseInterceptor 239 | }) 240 | return response.obj 241 | } 242 | 243 | /** 244 | * Create a cutout mask, and apply it to the input 245 | * 246 | * @param {string|Input} input Input file 247 | * @param {string|Output} output Output file 248 | * @returns {Job} Auto cutout job 249 | */ 250 | async createCutout (input, output) { 251 | try { 252 | const response = await this.sdk.apis.sensei.autoCutout({ 253 | 'x-gw-ims-org-id': this.orgId 254 | }, this.__createRequestOptions({ 255 | input: await this.fileResolver.resolveInput(input), 256 | output: await this.fileResolver.resolveOutput(output) 257 | })) 258 | 259 | const job = new Job(response.body, this.__getJobStatus.bind(this)) 260 | return await job.pollUntilDone() 261 | } catch (err) { 262 | throwError(err) 263 | } 264 | } 265 | 266 | /** 267 | * Create a cutout mask 268 | * 269 | * @param {string|Input} input Input file 270 | * @param {string|Output} output Output file 271 | * @returns {Job} Auto masking job 272 | */ 273 | async createMask (input, output) { 274 | try { 275 | const response = await this.sdk.apis.sensei.autoMask({ 276 | 'x-gw-ims-org-id': this.orgId 277 | }, this.__createRequestOptions({ 278 | input: await this.fileResolver.resolveInput(input), 279 | output: await this.fileResolver.resolveOutput(output) 280 | })) 281 | 282 | const job = new Job(response.body, this.__getJobStatus.bind(this)) 283 | return await job.pollUntilDone() 284 | } catch (err) { 285 | throwError(err) 286 | } 287 | } 288 | 289 | /** 290 | * Straighten photo 291 | * 292 | * @param {string|Input} input Input file 293 | * @param {string|Output|Output[]} outputs Output file 294 | * @returns {Job} Auto straighten job 295 | */ 296 | async straighten (input, outputs) { 297 | try { 298 | const response = await this.sdk.apis.lightroom.autoStraighten({ 299 | 'x-gw-ims-org-id': this.orgId 300 | }, this.__createRequestOptions({ 301 | inputs: await this.fileResolver.resolveInput(input), 302 | outputs: await this.fileResolver.resolveOutputs(outputs) 303 | })) 304 | 305 | const job = new Job(response.body, this.__getJobStatus.bind(this)) 306 | return await job.pollUntilDone() 307 | } catch (err) { 308 | throwError(err) 309 | } 310 | } 311 | 312 | /** 313 | * Automatically tone photo 314 | * 315 | * @param {string|Input} input Input file 316 | * @param {string|Output} output Output file 317 | * @returns {Job} Auto tone job 318 | */ 319 | async autoTone (input, output) { 320 | try { 321 | const response = await this.sdk.apis.lightroom.autoTone({ 322 | 'x-gw-ims-org-id': this.orgId 323 | }, this.__createRequestOptions({ 324 | inputs: await this.fileResolver.resolveInput(input), 325 | outputs: await this.fileResolver.resolveOutputs(output) 326 | })) 327 | 328 | const job = new Job(response.body, this.__getJobStatus.bind(this)) 329 | return await job.pollUntilDone() 330 | } catch (err) { 331 | throwError(err) 332 | } 333 | } 334 | 335 | /** 336 | * Apply a set of edit parameters on an image 337 | * 338 | * @param {string|Input} input Input file 339 | * @param {string|Output} output Output file 340 | * @param {EditPhotoOptions} options Edit options 341 | * @returns {Job} Edit photo job 342 | */ 343 | async editPhoto (input, output, options) { 344 | try { 345 | const response = await this.sdk.apis.lightroom.editPhoto({ 346 | 'x-gw-ims-org-id': this.orgId 347 | }, this.__createRequestOptions({ 348 | inputs: { 349 | source: await this.fileResolver.resolveInput(input) 350 | }, 351 | outputs: await this.fileResolver.resolveOutputs(output), 352 | options 353 | })) 354 | 355 | const job = new Job(response.body, this.__getJobStatus.bind(this)) 356 | return await job.pollUntilDone() 357 | } catch (err) { 358 | throwError(err) 359 | } 360 | } 361 | 362 | /** 363 | * Apply a preset on an image 364 | * 365 | * @param {string|Input} input Input file 366 | * @param {string|Input} preset Lightroom preset XMP file 367 | * @param {string|Output} output Output file 368 | * @returns {Job} Apply preset job 369 | */ 370 | async applyPreset (input, preset, output) { 371 | try { 372 | const response = await this.sdk.apis.lightroom.applyPreset({ 373 | 'x-gw-ims-org-id': this.orgId 374 | }, this.__createRequestOptions({ 375 | inputs: { 376 | source: await this.fileResolver.resolveInput(input), 377 | presets: await this.fileResolver.resolveInputs(preset) 378 | }, 379 | outputs: await this.fileResolver.resolveOutputs(output) 380 | })) 381 | 382 | const job = new Job(response.body, this.__getJobStatus.bind(this)) 383 | return await job.pollUntilDone() 384 | } catch (err) { 385 | throwError(err) 386 | } 387 | } 388 | 389 | /** 390 | * Apply a preset on an image 391 | * 392 | * @param {string|Input} input Input file 393 | * @param {string|Output} output Output file 394 | * @param {string} xmp Lightroom preset XMP file contents 395 | * @returns {Job} Apply preset job 396 | */ 397 | async applyPresetXmp (input, output, xmp) { 398 | try { 399 | const response = await this.sdk.apis.lightroom.applyPresetXmp({ 400 | 'x-gw-ims-org-id': this.orgId 401 | }, this.__createRequestOptions({ 402 | inputs: { 403 | source: await this.fileResolver.resolveInput(input) 404 | }, 405 | outputs: await this.fileResolver.resolveOutputs(output), 406 | options: { 407 | xmp 408 | } 409 | })) 410 | 411 | const job = new Job(response.body, this.__getJobStatus.bind(this)) 412 | return await job.pollUntilDone() 413 | } catch (err) { 414 | throwError(err) 415 | } 416 | } 417 | 418 | /** 419 | * Create a new psd, optionally with layers, and then generate renditions and/or save as a psd 420 | * 421 | * @param {string|string[]|Output|Output[]} outputs Desired output 422 | * @param {CreateDocumentOptions} options Document create options 423 | * @returns {Job} Create document job 424 | */ 425 | async createDocument (outputs, options) { 426 | try { 427 | const response = await this.sdk.apis.photoshop.createDocument({ 428 | 'x-gw-ims-org-id': this.orgId 429 | }, this.__createRequestOptions({ 430 | outputs: await this.fileResolver.resolveOutputs(outputs), 431 | options: await this.fileResolver.resolveInputsDocumentOptions(options) 432 | })) 433 | 434 | const job = new Job(response.body, this.__getJobStatus.bind(this)) 435 | return await job.pollUntilDone() 436 | } catch (err) { 437 | throwError(err) 438 | } 439 | } 440 | 441 | /** 442 | * Extract and return a psd file's layer information 443 | * 444 | * @param {string|Input} input An object describing an input PSD file.Current support is for files less than 1000MB. 445 | * @param {object} [options] available options to apply to all input files 446 | * @param {object} [options.thumbnails] Include presigned GET URLs to small preview thumbnails for any renderable layer. 447 | * @param {MimeType} [options.thumbnails.type] desired image format. Allowed values: "image/jpeg", "image/png", "image/tiff" 448 | * @returns {Job} Get document manifest job 449 | */ 450 | async getDocumentManifest (input, options) { 451 | try { 452 | const response = await this.sdk.apis.photoshop.getDocumentManifest({ 453 | 'x-gw-ims-org-id': this.orgId 454 | }, this.__createRequestOptions({ 455 | inputs: await this.fileResolver.resolveInputs(input), 456 | options 457 | })) 458 | 459 | const job = new Job(response.body, this.__getJobStatus.bind(this)) 460 | return await job.pollUntilDone() 461 | } catch (err) { 462 | throwError(err) 463 | } 464 | } 465 | 466 | /** 467 | * Apply (optional) psd edits and then generate renditions and/or save a new psd 468 | * 469 | * @param {string|Input} input An object describing an input PSD file. Current support is for files less than 1000MB. 470 | * @param {string|string[]|Output|Output[]} outputs Desired output 471 | * @param {ModifyDocumentOptions} options Modify document options 472 | * @returns {Job} Modify document job 473 | */ 474 | async modifyDocument (input, outputs, options) { 475 | try { 476 | const response = await this.sdk.apis.photoshop.modifyDocument({ 477 | 'x-gw-ims-org-id': this.orgId 478 | }, this.__createRequestOptions({ 479 | inputs: await this.fileResolver.resolveInputs(input), 480 | outputs: await this.fileResolver.resolveOutputs(outputs), 481 | options: await this.fileResolver.resolveInputsDocumentOptions(options) 482 | })) 483 | 484 | const job = new Job(response.body, this.__getJobStatus.bind(this)) 485 | return await job.pollUntilDone() 486 | } catch (err) { 487 | throwError(err) 488 | } 489 | } 490 | 491 | /** 492 | * Create renditions 493 | * 494 | * @param {string|Input} input An object describing an input file. Currently supported filetypes include: jpeg, png, psd, tiff. Current support is for files less than 1000MB. 495 | * @param {string|string[]|Output|Output[]} outputs Desired output 496 | * @returns {Job} Create rendition job 497 | */ 498 | async createRendition (input, outputs) { 499 | try { 500 | const response = await this.sdk.apis.photoshop.createRendition({ 501 | 'x-gw-ims-org-id': this.orgId 502 | }, this.__createRequestOptions({ 503 | inputs: await this.fileResolver.resolveInputs(input), 504 | outputs: await this.fileResolver.resolveOutputs(outputs) 505 | })) 506 | 507 | const job = new Job(response.body, this.__getJobStatus.bind(this)) 508 | return await job.pollUntilDone() 509 | } catch (err) { 510 | throwError(err) 511 | } 512 | } 513 | 514 | /** 515 | * Apply psd edits for replacing embedded smart object and then generate renditions and/or save a new psd 516 | * 517 | * @param {Input} input An object describing an input PSD file. Current support is for files less than 1000MB. 518 | * @param {string|Output|Output[]} outputs Desired output 519 | * @param {ReplaceSmartObjectOptions} options Replace smart object options 520 | * @returns {Job} Replace smart object job 521 | */ 522 | async replaceSmartObject (input, outputs, options) { 523 | try { 524 | const response = await this.sdk.apis.photoshop.replaceSmartObject({ 525 | 'x-gw-ims-org-id': this.orgId 526 | }, this.__createRequestOptions({ 527 | inputs: await this.fileResolver.resolveInputs(input), 528 | outputs: await this.fileResolver.resolveOutputs(outputs), 529 | options: await this.fileResolver.resolveInputsDocumentOptions(options) 530 | })) 531 | 532 | const job = new Job(response.body, this.__getJobStatus.bind(this)) 533 | return await job.pollUntilDone() 534 | } catch (err) { 535 | throwError(err) 536 | } 537 | } 538 | 539 | /** 540 | * Apply Photoshop Actions and then generate renditions and/or save a new image 541 | * 542 | * @param {Input} input An object describing an input image file. Current support is for files less than 1000MB. 543 | * @param {string|Output|Output[]} outputs Desired output 544 | * @param {ApplyPhotoshopActionsOptions} options Apply Photoshop Actions options 545 | * @returns {Job} Photoshop Actions job 546 | */ 547 | async applyPhotoshopActions (input, outputs, options) { 548 | try { 549 | const response = await this.sdk.apis.photoshop.applyPhotoshopActions({ 550 | 'x-gw-ims-org-id': this.orgId 551 | }, this.__createRequestOptions({ 552 | inputs: await this.fileResolver.resolveInputs(input), 553 | outputs: await this.fileResolver.resolveOutputs(outputs), 554 | options: await this.fileResolver.resolveInputsPhotoshopActionsOptions(options) 555 | })) 556 | 557 | const job = new Job(response.body, this.__getJobStatus.bind(this)) 558 | return await job.pollUntilDone() 559 | } catch (err) { 560 | throwError(err) 561 | } 562 | } 563 | 564 | /** 565 | * Apply JSON-formatted Photoshop Actions and then generate renditions and/or save a new image 566 | * 567 | * @param {Input} input An object describing an input image file. Current support is for files less than 1000MB. 568 | * @param {string|Output|Output[]} outputs Desired output 569 | * @param {ApplyPhotoshopActionsOptions} options Apply Photoshop Actions options 570 | * @returns {Job} Photoshop Actions job 571 | */ 572 | async applyPhotoshopActionsJson (input, outputs, options) { 573 | try { 574 | const response = await this.sdk.apis.photoshop.applyPhotoshopActionsJson({ 575 | 'x-gw-ims-org-id': this.orgId 576 | }, this.__createRequestOptions({ 577 | inputs: await this.fileResolver.resolveInputs(input), 578 | outputs: await this.fileResolver.resolveOutputs(outputs), 579 | options: await this.fileResolver.resolveInputsPhotoshopActionsOptions(options) 580 | })) 581 | 582 | const job = new Job(response.body, this.__getJobStatus.bind(this)) 583 | return await job.pollUntilDone() 584 | } catch (err) { 585 | throwError(err) 586 | } 587 | } 588 | } 589 | 590 | module.exports = { 591 | init, 592 | ...types 593 | } 594 | -------------------------------------------------------------------------------- /src/job.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Adobe. All rights reserved. 3 | This file is licensed to you under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. You may obtain a copy 5 | of the License at http://www.apache.org/licenses/LICENSE-2.0 6 | Unless required by applicable law or agreed to in writing, software distributed under 7 | the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS 8 | OF ANY KIND, either express or implied. See the License for the specific language 9 | governing permissions and limitations under the License. 10 | */ 11 | 'use strict' 12 | const sleep = (delay) => { 13 | return new Promise(resolve => { 14 | setTimeout(resolve, delay) 15 | }) 16 | } 17 | const { codes } = require('./SDKErrors') 18 | require('./types') 19 | 20 | /* global JobOutput */ 21 | 22 | /** 23 | * Abstraction around the Photoshop Services Jobs 24 | */ 25 | class Job { 26 | /** 27 | * Construct a job with the ability to acquire status updates 28 | * 29 | * @param {*} response Service response 30 | * @param {Function} getJobStatus Async function to get job status 31 | */ 32 | constructor (response, getJobStatus) { 33 | this.getJobStatus = getJobStatus 34 | 35 | /** 36 | * URL to request a status update of the job 37 | * 38 | * @type {string} 39 | */ 40 | this.url = response && response._links && response._links.self && response._links.self.href 41 | if (!this.url) { 42 | throw new codes.ERROR_STATUS_URL_MISSING({ messageValues: JSON.stringify(response) }) 43 | } 44 | 45 | /** 46 | * Job identifier 47 | * 48 | * @type {string} 49 | */ 50 | this.jobId = '' 51 | 52 | /** 53 | * Status of each output sub job 54 | * 55 | * @type {JobOutput[]} 56 | */ 57 | this.outputs = [] 58 | } 59 | 60 | /** 61 | * Check if the job is done 62 | * 63 | * A job is marked done when it has either the `succeeded` or `failed` status. 64 | * 65 | * @returns {boolean} True if the job is done, or false if it is still pending/running 66 | */ 67 | isDone () { 68 | for (const output of this.outputs) { 69 | if ((output.status !== 'succeeded') && (output.status !== 'failed')) { 70 | return false 71 | } 72 | } 73 | // return true if there was at least 1 output, otherwise poll hasn't been called yet 74 | return this.outputs.length > 0 75 | } 76 | 77 | /** 78 | * Poll for job status 79 | * 80 | * @returns {Job} Job instance 81 | */ 82 | async poll () { 83 | const response = await this.getJobStatus(this.url) 84 | 85 | // Image cutout and mask APIs only support a single output, map the input, status, errors fields to 86 | // the same structure as Lightroom and Photoshop for consistency. 87 | this.outputs = response.outputs || [] 88 | if (response.output || response.errors) { 89 | const output = { 90 | input: response.input, 91 | status: response.status 92 | } 93 | if (response.output) { 94 | output._links = { 95 | self: response.output 96 | } 97 | } 98 | if (response.errors) { 99 | output.errors = response.errors 100 | } 101 | this.outputs.push(output) 102 | } 103 | 104 | // Lightroom APIs provide created and modified in the root, but do have outputs 105 | if (response.created) { 106 | this.outputs.forEach(output => { 107 | output.created = response.created 108 | }) 109 | } 110 | if (response.modified) { 111 | this.outputs.forEach(output => { 112 | output.modified = response.modified 113 | }) 114 | } 115 | 116 | this.jobId = response.jobId || response.jobID 117 | this._links = response._links 118 | 119 | return this 120 | } 121 | 122 | /** 123 | * Poll job until done 124 | * 125 | * @param {number} [pollTimeMs=2000] Polling time 126 | * @returns {Job} Job instance 127 | */ 128 | async pollUntilDone (pollTimeMs = 2000) { 129 | while (!this.isDone()) { 130 | await sleep(pollTimeMs) 131 | await this.poll() 132 | } 133 | return this 134 | } 135 | } 136 | 137 | module.exports = { 138 | Job 139 | } 140 | -------------------------------------------------------------------------------- /src/types.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Adobe. All rights reserved. 3 | This file is licensed to you under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. You may obtain a copy 5 | of the License at http://www.apache.org/licenses/LICENSE-2.0 6 | Unless required by applicable law or agreed to in writing, software distributed under 7 | the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS 8 | OF ANY KIND, either express or implied. See the License for the specific language 9 | governing permissions and limitations under the License. 10 | */ 11 | 12 | 'use strict' 13 | 14 | /** 15 | * Storage types 16 | * 17 | * @readonly 18 | * @enum 19 | */ 20 | const Storage = { 21 | /** 22 | * href is a path in Adobe I/O Files: https://github.com/adobe/aio-lib-files 23 | */ 24 | AIO: 'aio', 25 | /** 26 | * href is a path in Creative Cloud 27 | */ 28 | ADOBE: 'adobe', 29 | /** 30 | * href is a presigned get/put url, e.g. AWS S3 31 | */ 32 | EXTERNAL: 'external', 33 | /** 34 | * href is an Azure SAS (Shared Access Signature) URL for upload/download 35 | */ 36 | AZURE: 'azure', 37 | /** 38 | * href is a temporary upload/download Dropbox link: https://dropbox.github.io/dropbox-api-v2-explorer/ 39 | */ 40 | DROPBOX: 'dropbox' 41 | } 42 | 43 | /** 44 | * Mime types 45 | * 46 | * @readonly 47 | * @enum 48 | */ 49 | const MimeType = { 50 | /** 51 | * Digital Negative, available from `autoTone`, `straighten`, `applyPreset` 52 | */ 53 | DNG: 'image/x-adobe-dng', 54 | /** 55 | * JPEG, available from all operations 56 | */ 57 | JPEG: 'image/jpeg', 58 | /** 59 | * PNG, available from all operations 60 | */ 61 | PNG: 'image/png', 62 | /** 63 | * Photoshop Document, available from `createDocument`, `modifyDocument`, `createRendition`, `replaceSmartObject` 64 | */ 65 | PSD: 'image/vnd.adobe.photoshop', 66 | /** 67 | * TIFF format, available from `createDocument`, `modifyDocument`, `createRendition`, `replaceSmartObject` 68 | */ 69 | TIFF: 'image/tiff' 70 | } 71 | 72 | /** 73 | * Compression level for PNG: small, medium or large. 74 | * 75 | * @readonly 76 | * @enum 77 | */ 78 | const PngCompression = { 79 | SMALL: 'small', 80 | MEDIUM: 'medium', 81 | LARGE: 'large' 82 | } 83 | 84 | /** 85 | * Color space 86 | * 87 | * @readonly 88 | * @enum 89 | */ 90 | const Colorspace = { 91 | BITMAP: 'bitmap', 92 | GREYSCALE: 'greyscale', 93 | INDEXED: 'indexed', 94 | RGB: 'rgb', 95 | CMYK: 'cmyk', 96 | MULTICHANNEL: 'multichannel', 97 | DUOTONE: 'duotone', 98 | LAB: 'lab' 99 | } 100 | 101 | /** 102 | * Standard ICC profile names 103 | * 104 | * @readonly 105 | * @enum 106 | */ 107 | const StandardIccProfileNames = { 108 | ADOBE_RGB_1998: 'Adobe RGB (1998)', 109 | APPLE_RGB: 'Apple RGB', 110 | COLORMATCH_RGB: 'ColorMatch RGB', 111 | SRGB: 'sRGB IEC61966-2.1', 112 | DOTGAIN_10: 'Dot Gain 10%', 113 | DOTGAIN_15: 'Dot Gain 15%', 114 | DOTGAIN_20: 'Dot Gain 20%', 115 | DOTGAIN_25: 'Dot Gain 25%', 116 | DOTGAIN_30: 'Dot Gain 30%', 117 | GRAY_GAMMA_18: 'Gray Gamma 1.8', 118 | GRAY_GAMMA_22: 'Gray Gamma 2.2' 119 | } 120 | 121 | /** 122 | * @typedef {object} Input 123 | * @description A reference to an input file 124 | * @property {string} href Either an href to a single Creative Cloud asset for storage='adobe' OR a presigned GET URL for other external services. 125 | * @property {Storage} [storage] Storage type, by default detected based on `href` 126 | */ 127 | 128 | /** 129 | * @typedef {object} IccProfile 130 | * @description Either referencing a standard profile from {@link StandardIccProfileNames} in `profileName`, or a custom profile through `input`. 131 | * @property {Colorspace} imageMode Image mode 132 | * @property {Input} input Custom ICC profile href to a Creative Cloud asset or presigned URL 133 | * @property {string} profileName Standard ICC profile name (e.g. `Adobe RGB (1998)`) 134 | */ 135 | 136 | /** 137 | * Type of mask to create 138 | * 139 | * @readonly 140 | * @enum 141 | */ 142 | const CreateMaskType = { 143 | /** 144 | * Binary mask 145 | */ 146 | BINARY: 'binary', 147 | 148 | /** 149 | * Soft mask 150 | */ 151 | SOFT: 'soft' 152 | } 153 | 154 | /** 155 | * @typedef {object} Output 156 | * @description A reference to an output file, including output options 157 | * @property {string} href (all) Either an href to a single Creative Cloud asset for storage='adobe' OR a presigned GET URL for other external services. 158 | * @property {Storage} [storage] (all) Storage type, by default detected based on `href` 159 | * @property {MimeType} [type] (all) Desired output image format, by default detected based on `href` extension 160 | * @property {boolean} [overwrite=true] (all) If the file already exists, indicates if the output file should be overwritten. Will eventually support eTags. Only applies to CC Storage 161 | * @property {object} [mask] (createMask, createCutout) Type of mask to create 162 | * @property {CreateMaskType} mask.format (createMask, createCutout) Binary or soft mask to create 163 | * @property {number} [width=0] (document) width, in pixels, of the renditions. Width of 0 generates a full size rendition. Height is not necessary as the rendition generate will automatically figure out the correct width-to-height aspect ratio. Only supported for image renditions 164 | * @property {number} [quality=7] (document) quality of the renditions for JPEG. Range from 1 to 7, with 7 as the highest quality. 165 | * @property {PngCompression} [compression=large] (document) compression level for PNG: small, medium or large 166 | * @property {boolean} [trimToCanvas=false] (document) 'false' generates renditions that are the actual size of the layer (as seen by View > Show > Layer Edges within the Photoshop desktop app) but will remove any extra transparent pixel padding. 'true' generates renditions that are the size of the canvas, either trimming the layer to the visible portion of the canvas or padding extra space. If the requested file format supports transparency than transparent pixels will be used for padding, otherwise white pixels will be used. 167 | * @property {LayerReference[]} [layers] (document) An array of layer objects. By including this array you are signaling that you'd like a rendition created from these layer id's or layer names. Excluding it will generate a document-level rendition. 168 | * @property {IccProfile} [iccProfile] (document) Describes the ICC profile to convert to 169 | */ 170 | 171 | /** 172 | * White balance enum 173 | * 174 | * @readonly 175 | * @enum 176 | */ 177 | const WhiteBalance = { 178 | AS_SHOT: 'As Shot', 179 | AUTO: 'Auto', 180 | CLOUDY: 'Cloudy', 181 | CUSTOM: 'Custom', 182 | DAYLIGHT: 'Daylight', 183 | FLASH: 'Flash', 184 | FLUORESCENT: 'Fluorescent', 185 | SHADE: 'Shade', 186 | TUNGSTEN: 'Tungsten' 187 | } 188 | 189 | /** 190 | * @typedef {object} EditPhotoOptions 191 | * @description Set of edit parameters to apply to an image 192 | * @property {number} Contrast integer [ -100 .. 100 ] 193 | * @property {number} Saturation integer [ -100 .. 100 ] 194 | * @property {number} VignetteAmount integer [ -100 .. 100 ] 195 | * @property {number} Vibrance integer [ -100 .. 100 ] 196 | * @property {number} Highlights integer [ -100 .. 100 ] 197 | * @property {number} Shadows integer [ -100 .. 100 ] 198 | * @property {number} Whites integer [ -100 .. 100 ] 199 | * @property {number} Blacks integer [ -100 .. 100 ] 200 | * @property {number} Clarity integer [ -100 .. 100 ] 201 | * @property {number} Dehaze integer [ -100 .. 100 ] 202 | * @property {number} Texture integer [ -100 .. 100 ] 203 | * @property {number} Sharpness integer [ 0 .. 150 ] 204 | * @property {number} ColorNoiseReduction integer [ 0 .. 100 ] 205 | * @property {number} NoiseReduction integer [ 0 .. 100 ] 206 | * @property {number} SharpenDetail integer [ 0 .. 100 ] 207 | * @property {number} SharpenEdgeMasking integer [ 0 .. 10 ] 208 | * @property {number} Exposure float [ -5 .. 5 ] 209 | * @property {number} SharpenRadius float [ 0.5 .. 3 ] 210 | * @property {WhiteBalance} WhiteBalance white balance 211 | */ 212 | 213 | /** 214 | * Action to take if there are one or more missing fonts in the document 215 | * 216 | * @readonly 217 | * @enum 218 | */ 219 | const ManageMissingFonts = { 220 | /** 221 | * The job will succeed, however, by default all the missing fonts will be replaced with this font: ArialMT 222 | */ 223 | USE_DEFAULT: 'useDefault', 224 | /** 225 | * The job will not succeed and the status will be set to "failed", with the details of the error provided in the "details" section in the status 226 | */ 227 | FAIL: 'fail' 228 | } 229 | 230 | /** 231 | * Background fill 232 | * 233 | * @readonly 234 | * @enum 235 | */ 236 | const BackgroundFill = { 237 | WHITE: 'white', 238 | BACKGROUND_COLOR: 'backgroundColor', 239 | TRANSPARENT: 'transparent' 240 | } 241 | 242 | /** 243 | * Layer type 244 | * 245 | * @readonly 246 | * @enum 247 | */ 248 | const LayerType = { 249 | /** 250 | * A pixel layer 251 | */ 252 | LAYER: 'layer', 253 | /** 254 | * A text layer 255 | */ 256 | TEXT_LAYER: 'textLayer', 257 | /** 258 | * An adjustment layer 259 | */ 260 | ADJUSTMENT_LAYER: 'adjustmentLayer', 261 | /** 262 | * Group of layers 263 | */ 264 | LAYER_SECTION: 'layerSection', 265 | /** 266 | * A smart object 267 | */ 268 | SMART_OBJECT: 'smartObject', 269 | /** 270 | * The background layer 271 | */ 272 | BACKGROUND_LAYER: 'backgroundLayer', 273 | /** 274 | * A fill layer 275 | */ 276 | FILL_LAYER: 'fillLayer' 277 | } 278 | 279 | /** 280 | * @typedef {object} Bounds 281 | * @description Layer bounds (in pixels) 282 | * @property {number} top Top position of the layer 283 | * @property {number} left Left position of the layer 284 | * @property {number} width Layer width 285 | * @property {number} height Layer height 286 | */ 287 | 288 | /** 289 | * @typedef {object} LayerMask 290 | * @description Mask applied to an layer 291 | * @property {boolean} [clip] Indicates if this is a clipped layer 292 | * @property {boolean} [enabled=true] Indicates a mask is enabled on that layer or not. 293 | * @property {boolean} [linked=true] Indicates a mask is linked to the layer or not. 294 | * @property {object} [offset] An object to specify mask offset on the layer. 295 | * @property {number} [offset.x=0] Offset to indicate horizontal move of the mask 296 | * @property {number} [offset.y=0] Offset to indicate vertical move of the mask 297 | */ 298 | 299 | /** 300 | * Blend modes 301 | * 302 | * @enum 303 | * @readonly 304 | */ 305 | const BlendMode = { 306 | NORMAL: 'normal', 307 | DISSOLVE: 'dissolve', 308 | DARKEN: 'darken', 309 | MULTIPLY: 'multiply', 310 | COLOR_BURN: 'colorBurn', 311 | LINEAR_BURN: 'linearBurn', 312 | DARKER_COLOR: 'darkerColor', 313 | LIGHTEN: 'lighten', 314 | SCREEN: 'screen', 315 | COLOR_DODGE: 'colorDodge', 316 | LINEAR_DODGE: 'linearDodge', 317 | LIGHTER_COLOR: 'lighterColor', 318 | OVERLAY: 'overlay', 319 | SOFT_LIGHT: 'softLight', 320 | HARD_LIGHT: 'hardLight', 321 | VIVID_LIGHT: 'vividLight', 322 | LINEAR_LIGHT: 'linearLight', 323 | PIN_LIGHT: 'pinLight', 324 | HARD_MIX: 'hardMix', 325 | DIFFERENCE: 'difference', 326 | EXCLUSION: 'exclusion', 327 | SUBTRACT: 'subtract', 328 | DIVIDE: 'divide', 329 | HUE: 'hue', 330 | SATURATION: 'saturation', 331 | COLOR: 'color', 332 | LUMINOSITY: 'luminosity' 333 | } 334 | 335 | /** 336 | * @typedef {object} BlendOptions 337 | * @description Layer blend options 338 | * @property {number} [opacity=100] Opacity value of the layer 339 | * @property {BlendMode} [blendMode="normal"] Blend mode of the layer 340 | */ 341 | 342 | /** 343 | * @typedef {object} BrightnessContrast 344 | * @description Adjustment layer brightness and contrast settings 345 | * @property {number} [brightness=0] Adjustment layer brightness (-150...150) 346 | * @property {number} [contrast=0] Adjustment layer contrast (-150...150) 347 | */ 348 | 349 | /** 350 | * @typedef {object} Exposure 351 | * @description Adjustment layer exposure settings 352 | * @property {number} [exposure=0] Adjustment layer exposure (-20...20) 353 | * @property {number} [offset=0] Adjustment layer exposure offset (-0.5...0.5) 354 | * @property {number} [gammaCorrection=1] Adjustment layer gamma correction (0.01...9.99) 355 | */ 356 | 357 | /** 358 | * @typedef {object} HueSaturationChannel 359 | * @description Master channel hue and saturation settings 360 | * @property {string} [channel="master"] Allowed values: "master" 361 | * @property {number} [hue=0] Hue adjustment (-180...180) 362 | * @property {number} [saturation=0] Saturation adjustment (-100...100) 363 | * @property {number} [lightness=0] Lightness adjustment (-100...100) 364 | */ 365 | 366 | /** 367 | * @typedef {object} HueSaturation 368 | * @description Adjustment layer hue and saturation settings 369 | * @property {boolean} [colorize=false] Colorize 370 | * @property {HueSaturationChannel[]} [channels=[]] An array of hashes representing the 'master' channel (the remaining five channels of 'magentas', 'yellows', 'greens', etc are not yet supported) 371 | */ 372 | 373 | /** 374 | * @typedef {object} ColorBalance 375 | * @description Adjustment layer color balance settings 376 | * @property {boolean} [preserveLuminosity=true] Preserve luminosity 377 | * @property {number[]} [shadowLevels=[0,0,0]] Shadow levels (-100...100) 378 | * @property {number[]} [midtoneLevels=[0,0,0]] Midtone levels (-100...100) 379 | * @property {number[]} [highlightLevels=[0,0,0]] Highlight levels (-100...100) 380 | */ 381 | 382 | /** 383 | * @typedef {object} AdjustmentLayer 384 | * @description Adjustment layer settings 385 | * @property {BrightnessContrast} [brightnessContrast] Brightness and contrast settings 386 | * @property {Exposure} [exposure] Exposure settings 387 | * @property {HueSaturation} [hueSaturation] Hue and saturation settings 388 | * @property {ColorBalance} [colorBalance] Color balance settings 389 | */ 390 | 391 | /** 392 | * Text orientation 393 | * 394 | * @enum 395 | * @readonly 396 | */ 397 | const TextOrientation = { 398 | HORIZONTAL: 'horizontal', 399 | VERTICAL: 'vertical' 400 | } 401 | 402 | /** 403 | * @typedef {object} FontColorRgb 404 | * @description Font color settings for RGB mode (16-bit) 405 | * @property {number} red Red color (0...32768) 406 | * @property {number} green Green color (0...32768) 407 | * @property {number} blue Blue color (0...32768) 408 | */ 409 | /** 410 | * @typedef {object} FontColorCmyk 411 | * @description Font color settings for CMYK mode (16-bit) 412 | * @property {number} cyan Cyan color (0...32768) 413 | * @property {number} magenta Magenta color (0...32768) 414 | * @property {number} yellowColor Yellow color (0...32768) 415 | * @property {number} black Black color (0...32768) 416 | */ 417 | /** 418 | * @typedef {object} FontColorGray 419 | * @description Font color settings for Gray mode (16-bit) 420 | * @property {number} gray Gray color (0...32768) 421 | */ 422 | /** 423 | * @typedef {object} FontColor 424 | * @description Font color settings 425 | * @property {FontColorRgb} rgb Font color settings for RGB mode (16-bit) 426 | * @property {FontColorCmyk} cmyk Font color settings for CMYK mode (16-bit) 427 | * @property {FontColorGray} gray Font color settings for Gray mode (16-bit) 428 | */ 429 | /** 430 | * @typedef {object} CharacterStyle 431 | * @description Character style settings 432 | * @property {number} [from] The beginning of the range of characters that this characterStyle applies to. Based on initial index of 0. For example a style applied to only the first two characters would be from=0 and to=1 433 | * @property {number} [to] The ending of the range of characters that this characterStyle applies to. Based on initial index of 0. For example a style applied to only the first two characters would be from=0 and to=1 434 | * @property {number} [fontSize] Font size (in points) 435 | * @property {string} [fontName] Font postscript name (see https://github.com/AdobeDocs/photoshop-api-docs/blob/master/SupportedFonts.md) 436 | * @property {TextOrientation} [orientation="horizontal"] Text orientation 437 | * @property {FontColor} [fontColor] The font color settings (one of rgb, cmyk, gray, lab) 438 | */ 439 | 440 | /** 441 | * Paragraph alignment 442 | * 443 | * @enum 444 | * @readonly 445 | */ 446 | const ParagraphAlignment = { 447 | LEFT: 'left', 448 | CENTER: 'center', 449 | RIGHT: 'right', 450 | JUSTIFY: 'justify', 451 | JUSTIFY_LEFT: 'justifyLeft', 452 | JUSTIFY_CENTER: 'justifyCenter', 453 | JUSTIFY_RIGHT: 'justifyRight' 454 | } 455 | 456 | /** 457 | * Horizontal alignment 458 | * 459 | * @enum 460 | * @readonly 461 | */ 462 | const HorizontalAlignment = { 463 | LEFT: 'left', 464 | CENTER: 'center', 465 | RIGHT: 'right' 466 | } 467 | 468 | /** 469 | * Vertical alignment 470 | * 471 | * @enum 472 | * @readonly 473 | */ 474 | const VerticalAlignment = { 475 | TOP: 'top', 476 | CENTER: 'center', 477 | BOTTOM: 'bottom' 478 | } 479 | 480 | /** 481 | * @typedef {object} ParagraphStyle 482 | * @description Paragraph style 483 | * @property {ParagraphAlignment} [alignment="left"] Paragraph alignment 484 | * @property {number} [from] The beginning of the range of characters that this paragraphStyle applies to. Based on initial index of 0. For example a style applied to only the first two characters would be from=0 and to=1 485 | * @property {number} [to] The ending of the range of characters that this characterStyle applies to. Based on initial index of 0. For example a style applied to only the first two characters would be from=0 and to=1 486 | */ 487 | 488 | /** 489 | * @typedef {object} TextLayer 490 | * @description Text layer settings 491 | * @property {string} content The text string 492 | * @property {CharacterStyle[]} [characterStyles] If the same supported attributes apply to all characters in the layer than this will be an array of one item, otherwise each characterStyle object will have a 'from' and 'to' value indicating the range of characters that the style applies to. 493 | * @property {ParagraphStyle[]} [paragraphStyles] If the same supported attributes apply to all characters in the layer than this will be an array of one item, otherwise each paragraphStyle object will have a 'from' and 'to' value indicating the range of characters that the style applies to. 494 | */ 495 | 496 | /** 497 | * @typedef {object} SmartObject 498 | * @description Smart object settings 499 | * @property {string} type Desired image format for the smart object 500 | * @property {boolean} [linked=false] Indicates if this smart object is linked. 501 | * @property {string} [path] Relative path for the linked smart object 502 | */ 503 | 504 | /** 505 | * @typedef {object} FillLayer 506 | * @description Fill layer settings 507 | * @property {object} solidColor An object describing the solid color type for this fill layer. Currently supported mode is RGB only. 508 | * @property {number} solidColor.red Red color (0...255) 509 | * @property {number} solidColor.green Green color (0...255) 510 | * @property {number} solidColor.blue Blue color (0...255) 511 | */ 512 | 513 | /** 514 | * @typedef {object} LayerReference 515 | * @description Layer reference 516 | * @property {number} [id] The id of the layer you want to move above. Use either id OR name. 517 | * @property {string} [name] The name of the layer you want to move above. Use either id OR name. 518 | */ 519 | 520 | /** 521 | * @typedef {object} AddLayerPosition 522 | * @description Position where to add the layer in the layer hierarchy 523 | * @property {LayerReference} [insertAbove] Used to add the layer above another. If the layer ID indicated is a group layer than the layer will be inserted above the group layer. 524 | * @property {LayerReference} [insertBelow] Used to add the layer below another. If the layer ID indicated is a group layer than the layer will be inserted below (and outside of) the group layer 525 | * @property {LayerReference} [insertInto] Used to add the layer inside of a group. Useful when you need to move a layer to an empty group. 526 | * @property {boolean} [insertTop] Indicates the layer should be added at the top of the layer stack. 527 | * @property {boolean} [insertBottom] Indicates the layer should be added at the bottom of the layer stack. If the image has a background image than the new layer will be inserted above it instead. 528 | */ 529 | 530 | /** 531 | * @typedef {object} MoveLayerPosition 532 | * @description Position where to move the layer to in the layer hierarchy 533 | * @property {boolean} [moveChildren=true] If layer is a group layer than true = move the set as a unit. Otherwise an empty group is moved and any children are left where they were, un-grouped. 534 | * @property {LayerReference} [insertAbove] Used to move the layer above another. If the layer ID indicated is a group layer than the layer will be inserted above the group layer. 535 | * @property {LayerReference} [insertBelow] Used to move the layer below another. If the layer ID indicated is a group layer than the layer will be inserted below (and outside of) the group layer 536 | * @property {LayerReference} [insertInto] Used to move the layer inside of a group. Useful when you need to move a layer to an empty group. 537 | * @property {boolean} [insertTop] Indicates the layer should be moved at the top of the layer stack. 538 | * @property {boolean} [insertBottom] Indicates the layer should be moved at the bottom of the layer stack. If the image has a background image than the new layer will be inserted above it instead. 539 | */ 540 | 541 | /** 542 | * @typedef {object} Layer 543 | * @description Layer to add, replace, move or delete when manipulating a Photoshop document, or retrieved from the manifest 544 | * @property {LayerType} type The layer type 545 | * @property {number} [id] (modify, manifest) The layer id 546 | * @property {number} [index] (modify, manifest) The layer index. Required when deleting a layer, otherwise not used 547 | * @property {Layer[]} [children] (manifest) An array of nested layer objects. Only layerSections (group layers) can include children 548 | * @property {string} [thumbnail] (manifest) If thumbnails were requested, a presigned GET URL to the thumbnail 549 | * @property {string} [name] Layer name 550 | * @property {boolean} [locked=false] Is the layer locked 551 | * @property {boolean} [visible=true] Is the layer visible 552 | * @property {Input} input (create, modify) An object describing the input file to add or replace for a Pixel or Embedded Smart object layer. Supported image types are PNG or JPEG. Images support bounds. If the bounds do not reflect the width and height of the image the image will be resized to fit the bounds. Smart object replacement supports PNG, JPEG, PSD, SVG, AI, PDF. Added images are always placed at (top,left = 0,0) and bounds are ignored. Edited images are replaced for exact pixel size 553 | * @property {AdjustmentLayer} [adjustments] Adjustment layer attributes 554 | * @property {Bounds} [bounds] The bounds of the layer, applicable to: LAYER, TEXT_LAYER, ADJUSTMENT_LAYER, LAYER_SECTION, SMART_OBJECT, FILL_LAYER 555 | * @property {LayerMask} [mask] An object describing the input mask to be added or replaced to the layer. Supported mask type is Layer Mask. The input file must be a greyscale image. Supported file types are jpeg, png and psd. 556 | * @property {SmartObject} [smartObject] An object describing the attributes specific to creating or editing a smartObject. SmartObject properties need the input smart object file to operate on, which can be obtained from Input block. Currently we support Embedded Smart Object only. So this block is optional. If you are creating a Linked Smart Object, this is a required block. 557 | * @property {FillLayer} [fill] Fill layer attributes 558 | * @property {TextLayer} [text] Text layer attributes 559 | * @property {BlendOptions} [blendOptions] Blend options of a layer, including opacity and blend mode 560 | * @property {boolean} [fillToCanvas=false] Indicates if this layer needs to be proportionally filled in to the entire canvas of the document. Applicable only to layer type="smartObject" or layer type="layer". 561 | * @property {HorizontalAlignment} [horizontalAlign] Indicates the horizontal position where this layer needs to be placed at. Applicable only to layer type="smartObject" or layer type="layer". 562 | * @property {VerticalAlignment} [verticalAlign] Indicates the vertical position where this layer needs to be placed at. Applicable only to layer type="smartObject" or layer type="layer". 563 | * @property {object} [edit] (modify) Indicates you want to edit the layer identified by it's id or name. Note the object is currently empty but leaves room for futher enhancements. The layer block should than contain changes from the original manifest. If you apply it to a group layer you will be effecting the attributes of the group layer itself, not the child layers 564 | * @property {MoveLayerPosition} [move] (modify) Indicates you want to move the layer identified by it's id or name. You must also indicate where you want to move the layer by supplying one of the attributes insertAbove, insertBelow, insertInto, insertTop or insertBottom 565 | * @property {AddLayerPosition} [add] (modify) Indicates you want to add a new layer. You must also indicate where you want to insert the new layer by supplying one of the attributes insertAbove, insertBelow, insertInto, insertTop or insertBottom After successful completion of this async request please call layers.read again in order to get a refreshed manifest with the latest layer indexes and any new layer id's. Currently supported layer types available for add are: layer, adjustmentLayer, textLayer, fillLayer 566 | * @property {boolean} [delete] (modify) Indicates you want to delete the layer, including any children, identified by the id or name. Note the object is currently empty but leaves room for futher enhancements. 567 | */ 568 | 569 | /** 570 | * @typedef {object} SmartObjectLayer 571 | * @description Smart object layer to add or replace 572 | * @property {number} [id] (modify, smart object, manifest) they layer id 573 | * @property {string} [name] (all) Layer name 574 | * @property {boolean} [locked=false] (all) Is the layer locked 575 | * @property {boolean} [visible=true] (all) Is the layer visible 576 | * @property {Input} input (create, modify, smart object) An object describing the input file to add or replace for a Pixel or Embedded Smart object layer. Supported image types are PNG or JPEG. Images support bounds. If the bounds do not reflect the width and height of the image the image will be resized to fit the bounds. Smart object replacement supports PNG, JPEG, PSD, SVG, AI, PDF. Added images are always placed at (top,left = 0,0) and bounds are ignored. Edited images are replaced for exact pixel size 577 | * @property {Bounds} [bounds] (all) The bounds of the layer, applicable to: LAYER, TEXT_LAYER, ADJUSTMENT_LAYER, LAYER_SECTION, SMART_OBJECT, FILL_LAYER 578 | * @property {AddLayerPosition} [add] (modify, smart object) Indicates you want to add a new layer. You must also indicate where you want to insert the new layer by supplying one of the attributes insertAbove, insertBelow, insertInto, insertTop or insertBottom After successful completion of this async request please call layers.read again in order to get a refreshed manifest with the latest layer indexes and any new layer id's. Currently supported layer types available for add are: layer, adjustmentLayer, textLayer, fillLayer 579 | */ 580 | 581 | /** 582 | * @typedef {object} ModifyDocumentOptions 583 | * @description Global Photoshop document modification options 584 | * @property {ManageMissingFonts} [manageMissingFonts='useDefault'] Action to take if there are one or more missing fonts in the document 585 | * @property {string} [globalFont] The full postscript name of the font to be used as the global default for the document. This font will be used for any text layer which has a missing font and no other font has been specifically provided for that layer. If this font itself is missing, the option specified for manageMissingFonts from above will take effect. 586 | * @property {Input[]} [fonts] Array of custom fonts needed in this document. Filename should be .otf 587 | * @property {object} [document] Document attributes 588 | * @property {object} [document.canvasSize] Crop parameters 589 | * @property {Bounds} [document.canvasSize.bounds] The bounds to crop the document 590 | * @property {object} [document.imageSize] Resize parameters. resizing a PSD always maintains the original aspect ratio by default. If the new width & height values specified in the parameters does not match the original aspect ratio, then the specified height will not be used and the height will be determined automatically 591 | * @property {number} [document.imageSize.width] Resize width 592 | * @property {number} [document.imageSize.height] Resize height 593 | * @property {object} [document.trim] Image trim parameters 594 | * @property {'transparentPixels'} [document.trim.basedOn='transparentPixels'] Type of pixel to trim 595 | * @property {Layer[]} [layers] An array of layer objects you wish to act upon (edit, add, delete). Any layer missing an "operations" block will be ignored. 596 | */ 597 | 598 | /** 599 | * @typedef {object} CreateDocumentOptions 600 | * @description Photoshop document create options 601 | * @property {ManageMissingFonts} [manageMissingFonts='useDefault'] Action to take if there are one or more missing fonts in the document 602 | * @property {string} [globalFont] The full postscript name of the font to be used as the global default for the document. This font will be used for any text layer which has a missing font and no other font has been specifically provided for that layer. If this font itself is missing, the option specified for manageMissingFonts from above will take effect. 603 | * @property {Input[]} [fonts] Array of custom fonts needed in this document. Filename should be .otf 604 | * @property {object} document Document attributes 605 | * @property {number} document.width Document width in pixels 606 | * @property {number} document.height Document height in pixels 607 | * @property {number} document.resolution Document resolution in pixels per inch. Allowed values: [72 ... 300]. 608 | * @property {BackgroundFill} document.fill Background fill 609 | * @property {Colorspace} document.mode Color space 610 | * @property {number} document.depth Bit depth. Allowed values: 8, 16, 32 611 | * @property {Layer[]} [layers] An array of layer objects representing the layers to be created, in the same order as provided (from top to bottom). 612 | */ 613 | 614 | /** 615 | * @typedef {object} DocumentManifest 616 | * @description Photoshop document manifest 617 | * @property {string} name Name of the input file 618 | * @property {number} width Document width in pixels 619 | * @property {number} height Document height in pixels 620 | * @property {string} photoshopBuild Name of the application that created the PSD 621 | * @property {Colorspace} imageMode Document image mode 622 | * @property {number} bitDepth Bit depth. Allowed values: 8, 16, 32 623 | */ 624 | 625 | /** 626 | * @typedef {object} ReplaceSmartObjectOptions 627 | * @description Replace Smart Object options 628 | * @property {SmartObjectLayer[]} layers An array of layer objects you wish to act upon (edit, add, delete). Any layer missing an "operations" block will be ignored. 629 | */ 630 | 631 | /** 632 | * Output status 633 | * 634 | * @enum 635 | * @readonly 636 | */ 637 | const JobOutputStatus = { 638 | /** 639 | * request has been accepted and is waiting to start 640 | */ 641 | PENDING: 'pending', 642 | /** 643 | * the child job is running 644 | */ 645 | RUNNING: 'running', 646 | /** 647 | * files have been generated and are uploading to destination 648 | */ 649 | UPLOADING: 'uploading', 650 | /** 651 | * the child job has succeeded 652 | */ 653 | SUCCEEDED: 'succeeded', 654 | /** 655 | * the child job has failed 656 | */ 657 | FAILED: 'failed' 658 | } 659 | 660 | /** 661 | * @typedef {object} JobError 662 | * @description Reported job errors 663 | * @property {string} type A machine readable error type 664 | * @property {string} code A machine readable error code 665 | * @property {string} title A short human readable error summary 666 | * @property {object[]} errorDetails Further descriptions of the exact errors where errorDetail is substituted for a specific issue. 667 | */ 668 | 669 | /** 670 | * @typedef {object} JobOutput 671 | * @description Job status and output 672 | * @property {string} input The original input file path 673 | * @property {JobOutputStatus} status Output status 674 | * @property {string} created Created timestamp of the job 675 | * @property {string} modified Modified timestamp of the job 676 | * @property {DocumentManifest} [document] (manifest) Information about the PSD file 677 | * @property {Layer[]} [layer] (manifest) A tree of layer objects representing the PSD layer structure extracted from the psd document 678 | * @property {object} [_links] Output references 679 | * @property {Output[]} [_links.renditions] (document) Created renditions 680 | * @property {Output} [_links.self] (lightroom, sensei) Created output 681 | * @property {JobError} [errors] Any errors reported 682 | */ 683 | 684 | /* exported CreateDocumentOptions EditPhotoOptions File Input ModifyDocumentOptions JobOutput Output ReplaceSmartObjectOptions */ 685 | 686 | module.exports = { 687 | BackgroundFill, 688 | BlendMode, 689 | Colorspace, 690 | CreateMaskType, 691 | HorizontalAlignment, 692 | LayerType, 693 | VerticalAlignment, 694 | ManageMissingFonts, 695 | MimeType, 696 | JobOutputStatus, 697 | ParagraphAlignment, 698 | PngCompression, 699 | StandardIccProfileNames, 700 | Storage, 701 | TextOrientation, 702 | WhiteBalance 703 | } 704 | -------------------------------------------------------------------------------- /templates/code-header.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright <%= YEAR %> Adobe. All rights reserved. 3 | This file is licensed to you under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. You may obtain a copy 5 | of the License at http://www.apache.org/licenses/LICENSE-2.0 6 | Unless required by applicable law or agreed to in writing, software distributed under 7 | the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS 8 | OF ANY KIND, either express or implied. See the License for the specific language 9 | governing permissions and limitations under the License. 10 | */ -------------------------------------------------------------------------------- /test/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "globals": { 3 | "fixtureFile": true, 4 | "fixtureJson": true, 5 | "fakeFileSystem": true, 6 | "fetch": true 7 | }, 8 | "rules": { 9 | "node/no-unpublished-require": 0 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /test/fileresolver.test.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Adobe. All rights reserved. 3 | This file is licensed to you under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. You may obtain a copy 5 | of the License at http://www.apache.org/licenses/LICENSE-2.0 6 | Unless required by applicable law or agreed to in writing, software distributed under 7 | the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS 8 | OF ANY KIND, either express or implied. See the License for the specific language 9 | governing permissions and limitations under the License. 10 | */ 11 | 12 | 'use strict' 13 | 14 | const { FileResolver } = require('../src/fileresolver') 15 | 16 | test('resolveInput', async () => { 17 | const resolver = new FileResolver() 18 | const result = await resolver.resolveInput({ 19 | href: 'https://host/path/to/image.png', 20 | storage: 'external' 21 | }) 22 | expect(result).toEqual({ 23 | href: 'https://host/path/to/image.png', 24 | storage: 'external' 25 | }) 26 | }) 27 | 28 | test('resolveOutput', async () => { 29 | const resolver = new FileResolver() 30 | const result = await resolver.resolveOutput({ 31 | href: 'https://host/path/to/image.png', 32 | storage: 'external', 33 | type: 'image/png' 34 | }) 35 | expect(result).toEqual({ 36 | href: 'https://host/path/to/image.png', 37 | storage: 'external', 38 | type: 'image/png' 39 | }) 40 | }) 41 | 42 | test('resolveInputAdobeAbsPath', async () => { 43 | const resolver = new FileResolver() 44 | const result = await resolver.resolveInput('/path/to/file') 45 | expect(result).toEqual({ 46 | href: '/path/to/file', 47 | storage: 'adobe' 48 | }) 49 | }) 50 | 51 | test('resolveInputAdobeRelPath', async () => { 52 | const resolver = new FileResolver() 53 | const result = await resolver.resolveInput('path/to/file') 54 | expect(result).toEqual({ 55 | href: 'path/to/file', 56 | storage: 'adobe' 57 | }) 58 | }) 59 | 60 | test('defaultAdobeCloudPaths', async () => { 61 | const resolver = new FileResolver({ 62 | generatePresignURL: (href, { permissions, expiryInSeconds }) => { 63 | return `https://host/${permissions}/${expiryInSeconds}/${href}` 64 | } 65 | }, { 66 | defaultAdobeCloudPaths: true 67 | }) 68 | const result = await resolver.resolveInput('path/to/file') 69 | expect(result).toEqual({ 70 | href: 'path/to/file', 71 | storage: 'adobe' 72 | }) 73 | }) 74 | 75 | test('resolveInputAIOAbsPath', async () => { 76 | const resolver = new FileResolver({ 77 | generatePresignURL: (href, { permissions, expiryInSeconds }) => { 78 | return `https://host/${permissions}/${expiryInSeconds}/${href}` 79 | } 80 | }) 81 | const result = await resolver.resolveInput('/path/to/file') 82 | expect(result).toEqual({ 83 | href: 'https://host/r/3600//path/to/file', 84 | storage: 'external' 85 | }) 86 | }) 87 | 88 | test('resolveInputAIORelPath', async () => { 89 | const resolver = new FileResolver({ 90 | generatePresignURL: (href, { permissions, expiryInSeconds }) => { 91 | return `https://host/${permissions}/${expiryInSeconds}/${href}` 92 | } 93 | }) 94 | const result = await resolver.resolveInput('path/to/file') 95 | expect(result).toEqual({ 96 | href: 'https://host/r/3600/path/to/file', 97 | storage: 'external' 98 | }) 99 | }) 100 | 101 | test('resolveInputAzureUrl', async () => { 102 | const resolver = new FileResolver() 103 | const result = await resolver.resolveInput('https://accountName.blob.core.windows.net/containerName') 104 | expect(result).toEqual({ 105 | href: 'https://accountName.blob.core.windows.net/containerName', 106 | storage: 'azure' 107 | }) 108 | }) 109 | 110 | test('resolveInputDropboxUrl', async () => { 111 | const resolver = new FileResolver() 112 | const result = await resolver.resolveInput('https://content.dropboxapi.com/xyz') 113 | expect(result).toEqual({ 114 | href: 'https://content.dropboxapi.com/xyz', 115 | storage: 'dropbox' 116 | }) 117 | }) 118 | 119 | test('resolveInputExternalUrl', async () => { 120 | const resolver = new FileResolver() 121 | const result = await resolver.resolveInput('https://www.adobe.com') 122 | expect(result).toEqual({ 123 | href: 'https://www.adobe.com', 124 | storage: 'external' 125 | }) 126 | }) 127 | 128 | test('resolveInputHrefAdobeAbsPath', async () => { 129 | const resolver = new FileResolver() 130 | const result = await resolver.resolveInput({ href: '/path/to/file' }) 131 | expect(result).toEqual({ 132 | href: '/path/to/file', 133 | storage: 'adobe' 134 | }) 135 | }) 136 | 137 | test('resolveInputHrefAdobeRelPath', async () => { 138 | const resolver = new FileResolver() 139 | const result = await resolver.resolveInput({ href: 'path/to/file' }) 140 | expect(result).toEqual({ 141 | href: 'path/to/file', 142 | storage: 'adobe' 143 | }) 144 | }) 145 | 146 | test('resolveInputHrefAIOPath', async () => { 147 | const resolver = new FileResolver({ 148 | generatePresignURL: (href, { permissions, expiryInSeconds }) => { 149 | return `https://host/${permissions}/${expiryInSeconds}/${href}` 150 | } 151 | }) 152 | const result = await resolver.resolveInput({ href: 'path/to/file' }) 153 | expect(result).toEqual({ 154 | href: 'https://host/r/3600/path/to/file', 155 | storage: 'external' 156 | }) 157 | }) 158 | 159 | test('resolveInputHrefAzureUrl', async () => { 160 | const resolver = new FileResolver() 161 | const result = await resolver.resolveInput({ href: 'https://accountName.blob.core.windows.net/containerName' }) 162 | expect(result).toEqual({ 163 | href: 'https://accountName.blob.core.windows.net/containerName', 164 | storage: 'azure' 165 | }) 166 | }) 167 | 168 | test('resolveInputHrefDropboxUrl', async () => { 169 | const resolver = new FileResolver() 170 | const result = await resolver.resolveInput({ href: 'https://content.dropboxapi.com/xyz' }) 171 | expect(result).toEqual({ 172 | href: 'https://content.dropboxapi.com/xyz', 173 | storage: 'dropbox' 174 | }) 175 | }) 176 | 177 | test('resolveInputHrefExternalUrl', async () => { 178 | const resolver = new FileResolver() 179 | const result = await resolver.resolveInput({ href: 'https://www.adobe.com' }) 180 | expect(result).toEqual({ 181 | href: 'https://www.adobe.com', 182 | storage: 'external' 183 | }) 184 | }) 185 | 186 | test('resolveInputNoHref', async () => { 187 | await expect(new FileResolver() 188 | .resolveInput({ }) 189 | ).rejects.toThrow(Error('Missing href: {}')) 190 | }) 191 | 192 | test('resolveInputNull', async () => { 193 | await expect(new FileResolver() 194 | .resolveInput(null) 195 | ).rejects.toThrow(Error('No file provided')) 196 | }) 197 | 198 | test('resolveInputUndefined', async () => { 199 | await expect(new FileResolver() 200 | .resolveInput() 201 | ).rejects.toThrow(Error('No file provided')) 202 | }) 203 | 204 | test('resolveOutputTypeDng', async () => { 205 | const resolver = new FileResolver() 206 | const result = await resolver.resolveOutput('/path/to/file.dng') 207 | expect(result).toEqual({ 208 | href: '/path/to/file.dng', 209 | storage: 'adobe', 210 | type: 'image/x-adobe-dng' 211 | }) 212 | }) 213 | 214 | test('resolveOutputTypeJpg', async () => { 215 | const resolver = new FileResolver() 216 | const result = await resolver.resolveOutput('/path/to/file.jpg') 217 | expect(result).toEqual({ 218 | href: '/path/to/file.jpg', 219 | storage: 'adobe', 220 | type: 'image/jpeg' 221 | }) 222 | }) 223 | 224 | test('resolveOutputTypeJpeg', async () => { 225 | const resolver = new FileResolver() 226 | const result = await resolver.resolveOutput('/path/to/file.jpeg') 227 | expect(result).toEqual({ 228 | href: '/path/to/file.jpeg', 229 | storage: 'adobe', 230 | type: 'image/jpeg' 231 | }) 232 | }) 233 | 234 | test('resolveOutputTypePng', async () => { 235 | const resolver = new FileResolver() 236 | const result = await resolver.resolveOutput('/path/to/file.png') 237 | expect(result).toEqual({ 238 | href: '/path/to/file.png', 239 | storage: 'adobe', 240 | type: 'image/png' 241 | }) 242 | }) 243 | 244 | test('resolveOutputTypePsb', async () => { 245 | const resolver = new FileResolver() 246 | const result = await resolver.resolveOutput('/path/to/file.psb') 247 | expect(result).toEqual({ 248 | href: '/path/to/file.psb', 249 | storage: 'adobe', 250 | type: 'image/vnd.adobe.photoshop' 251 | }) 252 | }) 253 | 254 | test('resolveOutputTypePsd', async () => { 255 | const resolver = new FileResolver() 256 | const result = await resolver.resolveOutput('/path/to/file.psd') 257 | expect(result).toEqual({ 258 | href: '/path/to/file.psd', 259 | storage: 'adobe', 260 | type: 'image/vnd.adobe.photoshop' 261 | }) 262 | }) 263 | 264 | test('resolveOutputTypeTif', async () => { 265 | const resolver = new FileResolver() 266 | const result = await resolver.resolveOutput('/path/to/file.tif') 267 | expect(result).toEqual({ 268 | href: '/path/to/file.tif', 269 | storage: 'adobe', 270 | type: 'image/tiff' 271 | }) 272 | }) 273 | 274 | test('resolveOutputTypeTiff', async () => { 275 | const resolver = new FileResolver() 276 | const result = await resolver.resolveOutput('/path/to/file.tiff') 277 | expect(result).toEqual({ 278 | href: '/path/to/file.tiff', 279 | storage: 'adobe', 280 | type: 'image/tiff' 281 | }) 282 | }) 283 | 284 | test('resolveOutputTypeUnknown', async () => { 285 | const resolver = new FileResolver() 286 | const result = await resolver.resolveOutput('/path/to/file.xxx') 287 | expect(result).toEqual({ 288 | href: '/path/to/file.xxx', 289 | storage: 'adobe', 290 | type: 'image/png' 291 | }) 292 | }) 293 | 294 | test('resolveOutputTypeAIOPath', async () => { 295 | const resolver = new FileResolver({ 296 | generatePresignURL: (href, { permissions, expiryInSeconds }) => { 297 | return `https://host/${permissions}/${expiryInSeconds}/${href}` 298 | } 299 | }) 300 | const result = await resolver.resolveOutput({ href: 'path/to/file.png' }) 301 | expect(result).toEqual({ 302 | href: 'https://host/rwd/3600/path/to/file.png', 303 | storage: 'external', 304 | type: 'image/png' 305 | }) 306 | }) 307 | 308 | test('resolveOutputTypeAzureUrl', async () => { 309 | const resolver = new FileResolver() 310 | const result = await resolver.resolveOutput({ href: 'https://accountName.blob.core.windows.net/containerName/file.png' }) 311 | expect(result).toEqual({ 312 | href: 'https://accountName.blob.core.windows.net/containerName/file.png', 313 | storage: 'azure', 314 | type: 'image/png' 315 | }) 316 | }) 317 | 318 | test('resolveOutputExternalUrl', async () => { 319 | const resolver = new FileResolver() 320 | const result = await resolver.resolveOutput({ href: 'https://host/path/to/file.png' }) 321 | expect(result).toEqual({ 322 | href: 'https://host/path/to/file.png', 323 | storage: 'external', 324 | type: 'image/png' 325 | }) 326 | }) 327 | 328 | test('resolveInputsDocumentOptionsUndefined', async () => { 329 | const resolver = new FileResolver() 330 | const result = await resolver.resolveInputsDocumentOptions() 331 | expect(result).toEqual(undefined) 332 | }) 333 | 334 | test('resolveInputsDocumentOptionsFonts', async () => { 335 | const resolver = new FileResolver() 336 | const result = await resolver.resolveInputsDocumentOptions({ 337 | fonts: ['https://host/path/to/font.ttf'] 338 | }) 339 | expect(result).toEqual({ 340 | fonts: [{ 341 | href: 'https://host/path/to/font.ttf', 342 | storage: 'external' 343 | }] 344 | }) 345 | }) 346 | 347 | test('resolveInputsDocumentOptionsLayersNoInput', async () => { 348 | const resolver = new FileResolver() 349 | const result = await resolver.resolveInputsDocumentOptions({ 350 | layers: [{}] 351 | }) 352 | expect(result).toEqual({ 353 | layers: [{}] 354 | }) 355 | }) 356 | 357 | test('resolveInputsDocumentOptionsLayersInput', async () => { 358 | const resolver = new FileResolver() 359 | const result = await resolver.resolveInputsDocumentOptions({ 360 | layers: [{ 361 | input: 'https://host/path/to/image.png' 362 | }] 363 | }) 364 | expect(result).toEqual({ 365 | layers: [{ 366 | input: { 367 | href: 'https://host/path/to/image.png', 368 | storage: 'external' 369 | } 370 | }] 371 | }) 372 | }) 373 | 374 | test('resolveInputsPhotoshopActionsOptionsUndefined', async () => { 375 | const resolver = new FileResolver() 376 | const result = await resolver.resolveInputsPhotoshopActionsOptions() 377 | expect(result).toEqual(undefined) 378 | }) 379 | 380 | test('resolveInputsPhotoshopActionsOptionsActions', async () => { 381 | const resolver = new FileResolver() 382 | const result = await resolver.resolveInputsPhotoshopActionsOptions({ 383 | actions: ['https://host/path/to/action.atn'] 384 | }) 385 | expect(result).toEqual({ 386 | actions: [{ 387 | href: 'https://host/path/to/action.atn', 388 | storage: 'external' 389 | }] 390 | }) 391 | }) 392 | 393 | test('resolveInputsPhotoshopActionsOptionsFonts', async () => { 394 | const resolver = new FileResolver() 395 | const result = await resolver.resolveInputsPhotoshopActionsOptions({ 396 | fonts: ['https://host/path/to/font.ttf'] 397 | }) 398 | expect(result).toEqual({ 399 | fonts: [{ 400 | href: 'https://host/path/to/font.ttf', 401 | storage: 'external' 402 | }] 403 | }) 404 | }) 405 | 406 | test('resolveInputsPhotoshopActionsOptionsPatterns', async () => { 407 | const resolver = new FileResolver() 408 | const result = await resolver.resolveInputsPhotoshopActionsOptions({ 409 | patterns: ['https://host/path/to/pattern.pat'] 410 | }) 411 | expect(result).toEqual({ 412 | patterns: [{ 413 | href: 'https://host/path/to/pattern.pat', 414 | storage: 'external' 415 | }] 416 | }) 417 | }) 418 | 419 | test('resolveInputsPhotoshopActionsOptionsBrushes', async () => { 420 | const resolver = new FileResolver() 421 | const result = await resolver.resolveInputsPhotoshopActionsOptions({ 422 | brushes: ['https://host/path/to/brush.abr'] 423 | }) 424 | expect(result).toEqual({ 425 | brushes: [{ 426 | href: 'https://host/path/to/brush.abr', 427 | storage: 'external' 428 | }] 429 | }) 430 | }) 431 | 432 | test('resolveInputsPhotoshopActionsJsonOptionsAdditionalImages', async () => { 433 | const resolver = new FileResolver() 434 | const result = await resolver.resolveInputsPhotoshopActionsOptions({ 435 | additionalImages: ['https://host/path/to/additional_image.png'] 436 | }) 437 | expect(result).toEqual({ 438 | additionalImages: [{ 439 | href: 'https://host/path/to/additional_image.png', 440 | storage: 'external' 441 | }] 442 | }) 443 | }) 444 | 445 | test('resolveInputsValue', async () => { 446 | const resolver = new FileResolver() 447 | const result = await resolver.resolveInputs('https://host/path/to/file.png') 448 | expect(result).toEqual([{ 449 | href: 'https://host/path/to/file.png', 450 | storage: 'external' 451 | }]) 452 | }) 453 | 454 | test('resolveInputsArray', async () => { 455 | const resolver = new FileResolver() 456 | const result = await resolver.resolveInputs([ 457 | 'https://host/path/to/file.png', 458 | 'https://accountName.blob.core.windows.net/containerName/file.png' 459 | ]) 460 | expect(result).toEqual([{ 461 | href: 'https://host/path/to/file.png', 462 | storage: 'external' 463 | }, { 464 | href: 'https://accountName.blob.core.windows.net/containerName/file.png', 465 | storage: 'azure' 466 | }]) 467 | }) 468 | 469 | test('resolveOutputsValue', async () => { 470 | const resolver = new FileResolver() 471 | const result = await resolver.resolveOutputs('https://host/path/to/file.png') 472 | expect(result).toEqual([{ 473 | href: 'https://host/path/to/file.png', 474 | storage: 'external', 475 | type: 'image/png' 476 | }]) 477 | }) 478 | 479 | test('resolveOutputsArray', async () => { 480 | const resolver = new FileResolver() 481 | const result = await resolver.resolveOutputs([ 482 | 'https://host/path/to/file.png', 483 | 'https://accountName.blob.core.windows.net/containerName/file.png' 484 | ]) 485 | expect(result).toEqual([{ 486 | href: 'https://host/path/to/file.png', 487 | storage: 'external', 488 | type: 'image/png' 489 | }, { 490 | href: 'https://accountName.blob.core.windows.net/containerName/file.png', 491 | storage: 'azure', 492 | type: 'image/png' 493 | }]) 494 | }) 495 | -------------------------------------------------------------------------------- /test/helpers.test.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Adobe. All rights reserved. 3 | This file is licensed to you under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. You may obtain a copy 5 | of the License at http://www.apache.org/licenses/LICENSE-2.0 6 | Unless required by applicable law or agreed to in writing, software distributed under 7 | the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS 8 | OF ANY KIND, either express or implied. See the License for the specific language 9 | governing permissions and limitations under the License. 10 | */ 11 | 12 | 'use strict' 13 | 14 | const { responseBodyToString, requestToString, reduceError, responseInterceptor, createRequestOptions, shouldRetryFetch, getFetchOptions } = require('../src/helpers') 15 | 16 | test('reduceError', () => { 17 | // no args produces empty object 18 | expect(reduceError()).toEqual({}) 19 | 20 | // unexpected properties returns the same error with no reduction 21 | const unexpectedError = { foo: 'bar' } 22 | expect(reduceError(unexpectedError)).toEqual(unexpectedError) 23 | 24 | // inadequate properties returns the same error with no reduction 25 | const unexpectedError2 = { foo: 'bar', response: {} } 26 | expect(reduceError(unexpectedError2)).toEqual(unexpectedError2) 27 | 28 | // expected properties returns the object reduced to a string 29 | const expectedError = { 30 | response: { 31 | status: 500, 32 | statusText: 'Something went gang aft agley.', 33 | body: { 34 | error_code: 500101, 35 | message: 'I\'m giving it all I got, cap\'n' 36 | } 37 | } 38 | } 39 | expect(reduceError(expectedError)).toEqual("500 - Something went gang aft agley. ({\"error_code\":500101,\"message\":\"I'm giving it all I got, cap'n\"})") 40 | }) 41 | 42 | test('createRequestOptions', () => { 43 | const orgId = 'my-org-id' 44 | const apiKey = 'my-api-key' 45 | const accessToken = 'my-token' 46 | 47 | const options = createRequestOptions({ 48 | orgId, 49 | apiKey, 50 | accessToken 51 | }) 52 | 53 | expect(options).toEqual({ 54 | requestBody: {}, 55 | securities: { 56 | authorized: { 57 | BearerAuth: { value: accessToken }, 58 | ApiKeyAuth: { value: apiKey } 59 | } 60 | } 61 | }) 62 | }) 63 | 64 | test('responseInterceptor', async () => { 65 | const res = {} 66 | expect(await responseInterceptor(res)).toEqual(res) 67 | }) 68 | 69 | test('responseBodyToString', async () => { 70 | const body = 'body contents' 71 | let res 72 | 73 | res = new fetch.Response(body) 74 | await expect(responseBodyToString(res)).resolves.toEqual(body) 75 | 76 | // error coverage 77 | res = { text: () => {} } 78 | await expect(responseBodyToString(res)).rejects.toEqual('TypeError: response.clone is not a function') 79 | }) 80 | 81 | test('requestToString', async () => { 82 | const url = 'http://foo.bar' 83 | let req, headers 84 | 85 | // no headers 86 | headers = {} 87 | req = { 88 | method: 'GET', 89 | headers, 90 | url 91 | } 92 | await expect(requestToString(req)).toEqual(JSON.stringify(req, null, 2)) 93 | 94 | // has headers 95 | headers = new Map() 96 | headers.set('Content-Type', 'application/json') 97 | req = { 98 | method: 'GET', 99 | headers, 100 | url 101 | } 102 | const result = Object.assign({}, req, { headers: { 'Content-Type': 'application/json' } }) 103 | await expect(requestToString(req)).toEqual(JSON.stringify(result, null, 2)) 104 | 105 | // error coverage 106 | const error = new Error('foo') 107 | req = { headers: { forEach: () => { throw error } } } 108 | await expect(requestToString(req)).toEqual(error.toString()) 109 | }) 110 | 111 | test('default retry handler', async () => { 112 | expect(shouldRetryFetch()).toBe(false) 113 | for (let code = 200; code < 429; code++) { 114 | expect(shouldRetryFetch({ status: code })).toBe(false) 115 | } 116 | expect(shouldRetryFetch({ status: 429 })).toBe(true) 117 | for (let code = 430; code < 500; code++) { 118 | expect(shouldRetryFetch({ status: code })).toBe(false) 119 | } 120 | for (let code = 500; code < 600; code++) { 121 | expect(shouldRetryFetch({ status: code })).toBe(true) 122 | } 123 | }) 124 | 125 | test('Use Swagger fetch', async () => { 126 | const opts = getFetchOptions({ 127 | useSwaggerFetch: true 128 | }) 129 | expect(opts.userFetch).toBe(undefined) 130 | }) 131 | 132 | test('Use custom fetch', async () => { 133 | const myFunction = (url, options) => 'Hello!' 134 | const opts = getFetchOptions({ 135 | userFetch: myFunction 136 | }) 137 | expect(opts.userFetch).toBe(myFunction) 138 | }) 139 | 140 | test('Use node-fetch-retry', async () => { 141 | expect(getFetchOptions().userFetch.isNodeFetchRetry).toBe(true) 142 | expect(getFetchOptions({}).userFetch.isNodeFetchRetry).toBe(true) 143 | expect(getFetchOptions({ retryOptions: {} }).userFetch.isNodeFetchRetry).toBe(true) 144 | expect(getFetchOptions().userFetch('url', 'options')).toMatchObject({}) 145 | }) 146 | -------------------------------------------------------------------------------- /test/jest.config.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Adobe. All rights reserved. 3 | This file is licensed to you under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. You may obtain a copy 5 | of the License at http://www.apache.org/licenses/LICENSE-2.0 6 | Unless required by applicable law or agreed to in writing, software distributed under 7 | the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS 8 | OF ANY KIND, either express or implied. See the License for the specific language 9 | governing permissions and limitations under the License. 10 | */ 11 | 12 | 'use strict' 13 | 14 | module.exports = { 15 | rootDir: '..', 16 | collectCoverage: true, 17 | collectCoverageFrom: [ 18 | '/src/**/*.js' 19 | ], 20 | coverageThreshold: { 21 | global: { 22 | branches: 70, 23 | lines: 70, 24 | statements: 70 25 | } 26 | }, 27 | reporters: [ 28 | 'default', 29 | 'jest-junit' 30 | ], 31 | testEnvironment: 'node', 32 | setupFilesAfterEnv: [ 33 | '/test/jest/jest.setup.js', 34 | // remove any of the lines below if you don't want to use any of the mocks 35 | '/test/jest/jest.fetch.setup.js', 36 | '/test/jest/jest.fs.setup.js', 37 | '/test/jest/jest.swagger.setup.js', 38 | '/test/jest/jest.fixture.setup.js' 39 | ] 40 | } 41 | -------------------------------------------------------------------------------- /test/jest/jest.fetch.setup.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Adobe. All rights reserved. 3 | This file is licensed to you under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. You may obtain a copy 5 | of the License at http://www.apache.org/licenses/LICENSE-2.0 6 | Unless required by applicable law or agreed to in writing, software distributed under 7 | the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS 8 | OF ANY KIND, either express or implied. See the License for the specific language 9 | governing permissions and limitations under the License. 10 | */ 11 | 12 | 'use strict' 13 | 14 | const fetch = require('jest-fetch-mock') 15 | 16 | jest.setMock('cross-fetch', fetch) 17 | jest.setMock('@adobe/node-fetch-retry', fetch) 18 | -------------------------------------------------------------------------------- /test/jest/jest.fixture.setup.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Adobe. All rights reserved. 3 | This file is licensed to you under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. You may obtain a copy 5 | of the License at http://www.apache.org/licenses/LICENSE-2.0 6 | Unless required by applicable law or agreed to in writing, software distributed under 7 | the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS 8 | OF ANY KIND, either express or implied. See the License for the specific language 9 | governing permissions and limitations under the License. 10 | */ 11 | 12 | 'use strict' 13 | 14 | /* global fixtureFile, fixtureJson */ 15 | 16 | const eol = require('eol') 17 | const fs = require('fs') 18 | 19 | const fixturesFolder = './test/__fixtures' 20 | 21 | // helper for fixtures 22 | global.fixtureFile = (output) => { 23 | return fs.readFileSync(`${fixturesFolder}/${output}`).toString() 24 | } 25 | 26 | // helper for fixtures 27 | global.fixtureJson = (output) => { 28 | return JSON.parse(fs.readFileSync(`${fixturesFolder}/${output}`).toString()) 29 | } 30 | 31 | // fixture matcher 32 | expect.extend({ 33 | toMatchFixture (received, argument) { 34 | const val = fixtureFile(argument) 35 | // eslint-disable-next-line jest/no-standalone-expect 36 | expect(eol.auto(received)).toEqual(eol.auto(val)) 37 | return { pass: true } 38 | } 39 | }) 40 | 41 | expect.extend({ 42 | toMatchFixtureJson (received, argument) { 43 | const val = fixtureJson(argument) 44 | // eslint-disable-next-line jest/no-standalone-expect 45 | expect(received).toEqual(val) 46 | return { pass: true } 47 | } 48 | }) 49 | -------------------------------------------------------------------------------- /test/jest/jest.fs.setup.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Adobe. All rights reserved. 3 | This file is licensed to you under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. You may obtain a copy 5 | of the License at http://www.apache.org/licenses/LICENSE-2.0 6 | Unless required by applicable law or agreed to in writing, software distributed under 7 | the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS 8 | OF ANY KIND, either express or implied. See the License for the specific language 9 | governing permissions and limitations under the License. 10 | */ 11 | 12 | 'use strict' 13 | 14 | /* global fakeFileSystem */ 15 | 16 | const fileSystem = require('jest-plugin-fs').default 17 | 18 | // dont touch the real fs 19 | jest.mock('fs', () => require('jest-plugin-fs/mock')) 20 | 21 | // set the fake filesystem 22 | global.fakeFileSystem = { 23 | addJson: (json) => { 24 | // add to existing 25 | fileSystem.mock(json) 26 | }, 27 | removeKeys: (arr) => { 28 | // remove from existing 29 | const files = fileSystem.files() 30 | for (const prop in files) { 31 | if (arr.includes(prop)) { 32 | delete files[prop] 33 | } 34 | } 35 | fileSystem.restore() 36 | fileSystem.mock(files) 37 | }, 38 | clear: () => { 39 | // reset to empty 40 | fileSystem.restore() 41 | }, 42 | reset: () => { 43 | // reset file system 44 | // TODO: add any defaults 45 | fileSystem.restore() 46 | }, 47 | files: () => { 48 | return fileSystem.files() 49 | } 50 | } 51 | // seed the fake filesystem 52 | fakeFileSystem.reset() 53 | -------------------------------------------------------------------------------- /test/jest/jest.setup.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Adobe. All rights reserved. 3 | This file is licensed to you under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. You may obtain a copy 5 | of the License at http://www.apache.org/licenses/LICENSE-2.0 6 | Unless required by applicable law or agreed to in writing, software distributed under 7 | the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS 8 | OF ANY KIND, either express or implied. See the License for the specific language 9 | governing permissions and limitations under the License. 10 | */ 11 | 12 | 'use strict' 13 | 14 | const { stdout } = require('stdout-stderr') 15 | 16 | process.env.CI = true 17 | 18 | jest.setTimeout(30000) 19 | 20 | // trap console log 21 | beforeEach(() => { stdout.start() }) 22 | afterEach(() => { stdout.stop() }) 23 | -------------------------------------------------------------------------------- /test/jest/jest.swagger.setup.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Adobe. All rights reserved. 3 | This file is licensed to you under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. You may obtain a copy 5 | of the License at http://www.apache.org/licenses/LICENSE-2.0 6 | Unless required by applicable law or agreed to in writing, software distributed under 7 | the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS 8 | OF ANY KIND, either express or implied. See the License for the specific language 9 | governing permissions and limitations under the License. 10 | */ 11 | 12 | 'use strict' 13 | 14 | const swaggerClient = require('./mocks/swagger-client') 15 | 16 | // ensure a mocked swagger-client module for unit-tests 17 | jest.setMock('swagger-client', swaggerClient) 18 | -------------------------------------------------------------------------------- /test/jest/mocks/swagger-client.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Adobe. All rights reserved. 3 | This file is licensed to you under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. You may obtain a copy 5 | of the License at http://www.apache.org/licenses/LICENSE-2.0 6 | Unless required by applicable law or agreed to in writing, software distributed under 7 | the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS 8 | OF ANY KIND, either express or implied. See the License for the specific language 9 | governing permissions and limitations under the License. 10 | */ 11 | 12 | 'use strict' 13 | 14 | /* global fixtureFile */ 15 | 16 | const mockSwaggerClient = { 17 | ...require.requireActual('swagger-client'), // we want the original implementation in there. Then we override the ones we want to mock below 18 | apis: {}, 19 | mockFn: function (methodName) { 20 | const cmd = methodName.split('.') 21 | let method = this.apis 22 | while (cmd.length > 1) { 23 | const word = cmd.shift() 24 | method = method[word] = method[word] || {} 25 | } 26 | method = method[cmd.shift()] = jest.fn() 27 | return method 28 | }, 29 | mockResolvedFixture: function (methodName, returnValue) { 30 | return this.mockResolved(methodName, returnValue, true) 31 | }, 32 | mockRejectedFixture: function (methodName, returnValue) { 33 | return this.mockRejected(methodName, returnValue, true) 34 | }, 35 | mockResolved: function (methodName, returnValue, isFile) { 36 | let val = (isFile) ? fixtureFile(returnValue) : returnValue 37 | try { 38 | val = JSON.parse(val) 39 | } catch (e) { } 40 | return this.mockFn(methodName).mockResolvedValue(val, isFile) 41 | }, 42 | mockRejected: function (methodName, err) { 43 | return this.mockFn(methodName).mockRejectedValue(err) 44 | } 45 | } 46 | 47 | module.exports = jest.fn(() => mockSwaggerClient) 48 | -------------------------------------------------------------------------------- /test/job.test.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Adobe. All rights reserved. 3 | This file is licensed to you under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. You may obtain a copy 5 | of the License at http://www.apache.org/licenses/LICENSE-2.0 6 | Unless required by applicable law or agreed to in writing, software distributed under 7 | the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS 8 | OF ANY KIND, either express or implied. See the License for the specific language 9 | governing permissions and limitations under the License. 10 | */ 11 | 12 | 'use strict' 13 | 14 | const { Job } = require('../src/job') 15 | 16 | test('missing-response', async () => { 17 | await expect(() => { 18 | // eslint-disable-next-line no-new 19 | new Job() 20 | }).toThrow('[PhotoshopSDK:ERROR_STATUS_URL_MISSING] Status URL is missing in the response: %s') 21 | }) 22 | 23 | test('missing-links', async () => { 24 | await expect(() => { 25 | // eslint-disable-next-line no-new 26 | new Job({}) 27 | }).toThrow('[PhotoshopSDK:ERROR_STATUS_URL_MISSING] Status URL is missing in the response: {}') 28 | }) 29 | 30 | test('missing-self', async () => { 31 | await expect(() => { 32 | // eslint-disable-next-line no-new 33 | new Job({ _links: { } }) 34 | }).toThrow('[PhotoshopSDK:ERROR_STATUS_URL_MISSING] Status URL is missing in the response: {"_links":{}}') 35 | }) 36 | 37 | test('missing-href', async () => { 38 | await expect(() => { 39 | // eslint-disable-next-line no-new 40 | new Job({ _links: { self: { } } }) 41 | }).toThrow('[PhotoshopSDK:ERROR_STATUS_URL_MISSING] Status URL is missing in the response: {"_links":{"self":{}}}') 42 | }) 43 | 44 | test('valid-status-url', async () => { 45 | const job = new Job({ _links: { self: { href: 'http://host/status' } } }) 46 | expect(job).toEqual({ 47 | getJobStatus: undefined, 48 | jobId: '', 49 | outputs: [], 50 | url: 'http://host/status' 51 | }) 52 | }) 53 | 54 | test('done-no-outputs', async () => { 55 | const job = new Job({ _links: { self: { href: 'http://host/status' } } }) 56 | expect(job.isDone()).toEqual(false) 57 | }) 58 | 59 | test('done-single-output-nostatus', async () => { 60 | const job = new Job({ _links: { self: { href: 'http://host/status' } } }) 61 | job.outputs = [{}] 62 | expect(job.isDone()).toEqual(false) 63 | }) 64 | 65 | test('done-single-output-succeeded', async () => { 66 | const job = new Job({ _links: { self: { href: 'http://host/status' } } }) 67 | job.outputs = [{ status: 'succeeded' }] 68 | expect(job.isDone()).toEqual(true) 69 | }) 70 | 71 | test('done-single-output-failed', async () => { 72 | const job = new Job({ _links: { self: { href: 'http://host/status' } } }) 73 | job.outputs = [{ status: 'failed' }] 74 | expect(job.isDone()).toEqual(true) 75 | }) 76 | 77 | test('done-multi-output-incomplete1', async () => { 78 | const job = new Job({ _links: { self: { href: 'http://host/status' } } }) 79 | job.outputs = [{ status: 'failed' }, { status: 'running' }] 80 | expect(job.isDone()).toEqual(false) 81 | }) 82 | 83 | test('done-multi-output-incomplete2', async () => { 84 | const job = new Job({ _links: { self: { href: 'http://host/status' } } }) 85 | job.outputs = [{ status: 'running' }, { status: 'failed' }] 86 | expect(job.isDone()).toEqual(false) 87 | }) 88 | 89 | test('done-multi-output-complete1', async () => { 90 | const job = new Job({ _links: { self: { href: 'http://host/status' } } }) 91 | job.outputs = [{ status: 'failed' }, { status: 'succeeded' }] 92 | expect(job.isDone()).toEqual(true) 93 | }) 94 | 95 | test('done-multi-output-complete2', async () => { 96 | const job = new Job({ _links: { self: { href: 'http://host/status' } } }) 97 | job.outputs = [{ status: 'succeeded' }, { status: 'failed' }] 98 | expect(job.isDone()).toEqual(true) 99 | }) 100 | 101 | test('poll-response-no-output', async () => { 102 | const getJobStatus = () => ({}) 103 | const job = new Job({ 104 | _links: { self: { href: 'http://host/status' } } 105 | }, getJobStatus) 106 | await job.poll() 107 | expect(job).toEqual({ 108 | _links: undefined, 109 | getJobStatus, 110 | jobId: undefined, 111 | outputs: [], 112 | url: 'http://host/status' 113 | }) 114 | }) 115 | 116 | test('poll-response-cutout-pending', async () => { 117 | const getJobStatus = () => ({ 118 | jobID: 'c900e70c-03b2-43dc-b6f0-b0db16333b4b', 119 | status: 'pending', 120 | _links: { 121 | self: { 122 | href: 'https://image.adobe.io/sensei/status/c900e70c-03b2-43dc-b6f0-b0db16333b4b' 123 | } 124 | } 125 | }) 126 | const job = new Job({ 127 | _links: { self: { href: 'http://host/status' } } 128 | }, getJobStatus) 129 | await job.poll() 130 | expect(job).toEqual({ 131 | _links: { 132 | self: { 133 | href: 'https://image.adobe.io/sensei/status/c900e70c-03b2-43dc-b6f0-b0db16333b4b' 134 | } 135 | }, 136 | getJobStatus, 137 | jobId: 'c900e70c-03b2-43dc-b6f0-b0db16333b4b', 138 | outputs: [], 139 | url: 'http://host/status' 140 | }) 141 | }) 142 | 143 | test('poll-response-cutout-succeeded', async () => { 144 | const getJobStatus = () => ({ 145 | jobID: 'c900e70c-03b2-43dc-b6f0-b0db16333b4b', 146 | status: 'succeeded', 147 | input: '/files/images/input.jpg', 148 | output: { 149 | storage: 'adobe', 150 | href: '/files/cutout/output/mask.png', 151 | mask: { 152 | format: 'binary' 153 | }, 154 | color: { 155 | space: 'rgb' 156 | } 157 | }, 158 | _links: { 159 | self: { 160 | href: 'https://image.adobe.io/sensei/status/c900e70c-03b2-43dc-b6f0-b0db16333b4b' 161 | } 162 | } 163 | }) 164 | const job = new Job({ 165 | _links: { self: { href: 'http://host/status' } } 166 | }, getJobStatus) 167 | await job.poll() 168 | expect(job).toEqual({ 169 | _links: { 170 | self: { 171 | href: 'https://image.adobe.io/sensei/status/c900e70c-03b2-43dc-b6f0-b0db16333b4b' 172 | } 173 | }, 174 | getJobStatus, 175 | jobId: 'c900e70c-03b2-43dc-b6f0-b0db16333b4b', 176 | outputs: [{ 177 | status: 'succeeded', 178 | input: '/files/images/input.jpg', 179 | _links: { 180 | self: { 181 | storage: 'adobe', 182 | href: '/files/cutout/output/mask.png', 183 | mask: { 184 | format: 'binary' 185 | }, 186 | color: { 187 | space: 'rgb' 188 | } 189 | } 190 | } 191 | }], 192 | url: 'http://host/status' 193 | }) 194 | }) 195 | 196 | test('poll-response-cutout-failed', async () => { 197 | const getJobStatus = () => ({ 198 | jobID: 'c900e70c-03b2-43dc-b6f0-b0db16333b4b', 199 | status: 'failed', 200 | input: '/files/images/input.jpg', 201 | errors: { 202 | type: '', 203 | code: '', 204 | title: '', 205 | '': [ 206 | { 207 | name: '', 208 | reason: '' 209 | } 210 | ] 211 | }, 212 | _links: { 213 | self: { 214 | href: 'https://image.adobe.io/sensei/status/c900e70c-03b2-43dc-b6f0-b0db16333b4b' 215 | } 216 | } 217 | }) 218 | const job = new Job({ 219 | _links: { self: { href: 'http://host/status' } } 220 | }, getJobStatus) 221 | await job.poll() 222 | expect(job).toEqual({ 223 | _links: { 224 | self: { 225 | href: 'https://image.adobe.io/sensei/status/c900e70c-03b2-43dc-b6f0-b0db16333b4b' 226 | } 227 | }, 228 | getJobStatus, 229 | jobId: 'c900e70c-03b2-43dc-b6f0-b0db16333b4b', 230 | outputs: [{ 231 | status: 'failed', 232 | input: '/files/images/input.jpg', 233 | errors: { 234 | type: '', 235 | code: '', 236 | title: '', 237 | '': [ 238 | { 239 | name: '', 240 | reason: '' 241 | } 242 | ] 243 | } 244 | }], 245 | url: 'http://host/status' 246 | }) 247 | }) 248 | 249 | test('poll-autostraighten-success', async () => { 250 | const response = { 251 | jobId: 'f54e0fcb-260b-47c3-b520-de0d17dc2b67', 252 | created: '2018-01-04T12:57:15.12345:Z', 253 | modified: '2018-01-04T12:58:36.12345:Z', 254 | outputs: [{ 255 | input: '/some_project/photo.jpg', 256 | status: 'pending' 257 | }], 258 | _links: { 259 | self: { 260 | href: 'https://image.adobe.io/lrService/status/f54e0fcb-260b-47c3-b520-de0d17dc2b67' 261 | } 262 | } 263 | } 264 | const getJobStatus = () => response 265 | const job = new Job({ 266 | _links: { self: { href: 'http://host/status' } } 267 | }, getJobStatus) 268 | await job.poll() 269 | expect(job).toEqual({ 270 | getJobStatus, 271 | url: 'http://host/status', 272 | _links: { 273 | self: { 274 | href: 'https://image.adobe.io/lrService/status/f54e0fcb-260b-47c3-b520-de0d17dc2b67' 275 | } 276 | }, 277 | jobId: 'f54e0fcb-260b-47c3-b520-de0d17dc2b67', 278 | outputs: [{ 279 | created: '2018-01-04T12:57:15.12345:Z', 280 | modified: '2018-01-04T12:58:36.12345:Z', 281 | input: '/some_project/photo.jpg', 282 | status: 'pending' 283 | }] 284 | }) 285 | }) 286 | 287 | test('poll-response-document-operations', async () => { 288 | const response = { 289 | jobId: 'f54e0fcb-260b-47c3-b520-de0d17dc2b67', 290 | outputs: [{ 291 | input: '/files/some_project/design1.psd', 292 | status: 'pending', 293 | created: '2018-01-04T12:57:15.12345:Z', 294 | modified: '2018-01-04T12:58:36.12345:Z' 295 | }, { 296 | input: 'https://some-bucket-us-east-1.amazonaws.com/s3_presigned_getObject...', 297 | status: 'running', 298 | created: '2018-01-04T12:57:15.12345:Z', 299 | modified: '2018-01-04T12:58:36.12345:Z' 300 | }, { 301 | input: '/files/some_project/design2.psd', 302 | status: 'succeeded', 303 | created: '2018-01-04T12:57:15.12345:Z', 304 | modified: '2018-01-04T12:58:36.12345:Z', 305 | _links: { 306 | renditions: [{ 307 | href: '/files/some_project/OUTPUT/design2_new.psd', 308 | storage: 'adobe', 309 | width: '500', 310 | type: 'image/jpeg', 311 | trimToCanvas: false, 312 | layers: [{ 313 | id: 77 314 | }] 315 | }] 316 | } 317 | }, { 318 | input: 'https://some-bucket-us-east-1.amazonaws.com/s3_presigned_getObject...', 319 | status: 'failed', 320 | created: '2018-01-04T12:57:15.12345:Z', 321 | modified: '2018-01-04T12:58:36.12345:Z', 322 | error: { 323 | type: 'InputValidationError', 324 | title: "request parameters didn't validate", 325 | code: '400', 326 | invalidParams: [{ 327 | name: 'contrast', 328 | reason: 'value must be an int between -150 and 150' 329 | }, { 330 | name: 'exposure', 331 | reason: 'must be bool' 332 | }] 333 | } 334 | }], 335 | _links: { 336 | self: { 337 | href: 'https://image.adobe.io/pie/psdService/status/f54e0fcb-260b-47c3-b520-de0d17dc2b67' 338 | } 339 | } 340 | } 341 | const getJobStatus = () => response 342 | const job = new Job({ 343 | _links: { self: { href: 'http://host/status' } } 344 | }, getJobStatus) 345 | await job.poll() 346 | expect(job).toEqual({ 347 | ...response, 348 | getJobStatus, 349 | url: 'http://host/status' 350 | }) 351 | }) 352 | 353 | test('poll-until-done-succeeded', async () => { 354 | let iteration = 0 355 | const getJobStatus = () => { 356 | if (iteration === 0) { 357 | ++iteration 358 | return { 359 | jobID: 'c900e70c-03b2-43dc-b6f0-b0db16333b4b', 360 | status: 'pending' 361 | } 362 | } else if (iteration === 1) { 363 | ++iteration 364 | return { 365 | jobID: 'c900e70c-03b2-43dc-b6f0-b0db16333b4b', 366 | status: 'succeeded', 367 | output: { 368 | storage: 'adobe', 369 | href: '/files/output.png' 370 | } 371 | } 372 | } else { 373 | throw Error('invalid call') 374 | } 375 | } 376 | 377 | const job = new Job({ 378 | _links: { self: { href: 'http://host/status' } } 379 | }, getJobStatus) 380 | await job.pollUntilDone() 381 | expect(job).toEqual({ 382 | getJobStatus, 383 | url: 'http://host/status', 384 | jobId: 'c900e70c-03b2-43dc-b6f0-b0db16333b4b', 385 | outputs: [{ 386 | status: 'succeeded', 387 | _links: { 388 | self: { 389 | storage: 'adobe', 390 | href: '/files/output.png' 391 | } 392 | } 393 | }] 394 | }) 395 | }) 396 | -------------------------------------------------------------------------------- /testfiles/Auto-BW.xmp: -------------------------------------------------------------------------------- 1 | 2 | 3 | 24 | 25 | 26 | Auto-BW 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 51 | 52 | 53 | 54 | -------------------------------------------------------------------------------- /testfiles/Example.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adobe/aio-lib-photoshop-api/6aba4f232774beecb4c4d6c852a5dd0de8193271/testfiles/Example.jpg -------------------------------------------------------------------------------- /testfiles/Example.psd: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adobe/aio-lib-photoshop-api/6aba4f232774beecb4c4d6c852a5dd0de8193271/testfiles/Example.psd -------------------------------------------------------------------------------- /testfiles/Layer Comps.psd: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adobe/aio-lib-photoshop-api/6aba4f232774beecb4c4d6c852a5dd0de8193271/testfiles/Layer Comps.psd -------------------------------------------------------------------------------- /testfiles/Sunflower.psd: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adobe/aio-lib-photoshop-api/6aba4f232774beecb4c4d6c852a5dd0de8193271/testfiles/Sunflower.psd -------------------------------------------------------------------------------- /testfiles/heroImage.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adobe/aio-lib-photoshop-api/6aba4f232774beecb4c4d6c852a5dd0de8193271/testfiles/heroImage.png --------------------------------------------------------------------------------