├── .github ├── dependabot.yml └── workflows │ ├── conventional-commit.yml │ ├── dependabot-test.yml │ ├── release.yml │ └── run-tests.yml ├── .gitignore ├── .goreleaser.yaml ├── CODE-OF-CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── Makefile ├── NOTICE ├── README.md ├── api.go ├── api_suite_test.go ├── api_test.go ├── auth ├── auth.go ├── auth_suite_test.go └── auth_test.go ├── catalog.go ├── catalog_test.go ├── context_utils.go ├── context_utils_test.go ├── create_version_dir.sh ├── domain ├── apiresponses │ ├── apiresponses_suite_test.go │ ├── errors.go │ ├── failure_responses.go │ ├── failure_responses_test.go │ ├── responses.go │ └── responses_test.go ├── domain_suite_test.go ├── experimental_volume_mount.go ├── maintenance_info.go ├── maintenance_info_test.go ├── service_broker.go ├── service_catalog.go ├── service_catalog_test.go ├── service_metadata.go ├── service_metadata_test.go ├── service_plan_metadata.go └── service_plan_metadata_test.go ├── failure_response.go ├── failure_response_test.go ├── fakes ├── auto_fake_service_broker.go ├── fake_service_broker.go └── staticcheck.conf ├── fixtures ├── async_bind_response.json ├── async_required.json ├── binding.json ├── binding_with_experimental_volume_mounts.json ├── binding_with_route_service.json ├── binding_with_syslog.json ├── binding_with_volume_mounts.json ├── catalog.json ├── get_instance.json ├── instance_limit_error.json ├── last_operation_succeeded.json ├── operation_data_response.json ├── provisioning.json ├── provisioning_with_dashboard.json └── updating_with_dashboard.json ├── go.mod ├── go.sum ├── handlers ├── api_handler.go ├── bind.go ├── catalog.go ├── catalog_test.go ├── deprovision.go ├── fakes │ └── fake_response_writer.go ├── get_binding.go ├── get_instance.go ├── handlers_suite_test.go ├── last_binding_operation.go ├── last_binding_operation_test.go ├── last_operation.go ├── provision.go ├── unbind.go └── update.go ├── internal ├── blog │ ├── blog.go │ ├── blog_suite_test.go │ └── blog_test.go └── middleware │ ├── middleware.go │ ├── middleware_suite_test.go │ └── middleware_test.go ├── maintenance_info.go ├── maintenance_info_test.go ├── middlewares ├── api_version_header.go ├── context_keys.go ├── correlation_id_header.go ├── info_location_header.go ├── originating_identity_header.go └── request_identity_header.go ├── response.go ├── response_test.go ├── service_broker.go ├── staticcheck.conf ├── tools └── tools.go └── utils ├── context.go ├── context_test.go └── utils_suite_test.go /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: gomod 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | time: '01:00' 8 | open-pull-requests-limit: 5 9 | - package-ecosystem: "github-actions" 10 | directory: "/" 11 | schedule: 12 | interval: daily 13 | time: '01:00' 14 | open-pull-requests-limit: 5 15 | -------------------------------------------------------------------------------- /.github/workflows/conventional-commit.yml: -------------------------------------------------------------------------------- 1 | name: Conventional Commit Check 2 | 3 | on: [pull_request] 4 | 5 | jobs: 6 | conventional-commit: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - name: Check 10 | uses: actions/github-script@v7 11 | with: 12 | script: | 13 | const {data: pr} = await github.rest.pulls.get({ 14 | owner: context.repo.owner, 15 | repo: context.repo.repo, 16 | pull_number: context.issue.number, 17 | }) 18 | 19 | const allowed = ["feat\\!", "feat", "fix", "chore", "docs", "build", "test", "revert"] 20 | const re = new RegExp(`^(` + allowed.join('|') + `)(\\(\\w+\\))?: `) 21 | const title = pr['title'] 22 | 23 | if (!re.test(title)) { 24 | throw new Error(`PR title "${title}" does not match conventional commits filter: ${re}`) 25 | } 26 | -------------------------------------------------------------------------------- /.github/workflows/dependabot-test.yml: -------------------------------------------------------------------------------- 1 | name: dependabot-pr-merge 2 | on: 3 | workflow_call: 4 | inputs: 5 | pr_number: 6 | description: "The PR number" 7 | required: true 8 | type: string 9 | 10 | jobs: 11 | automerge: 12 | name: Merge Dependabot Pull Pequest 13 | runs-on: ubuntu-latest 14 | permissions: 15 | contents: write 16 | if: ${{ github.actor == 'dependabot[bot]' }} 17 | steps: 18 | - name: Merge 19 | uses: actions/github-script@v7 20 | with: 21 | github-token: ${{secrets.GITHUB_TOKEN}} 22 | script: | 23 | var pr_number = ${{ inputs.pr_number }} 24 | github.rest.pulls.merge({ 25 | owner: context.repo.owner, 26 | repo: context.repo.repo, 27 | pull_number: pr_number, 28 | merge_method: 'squash' 29 | }) 30 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: goreleaser 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | workflow_dispatch: 8 | 9 | jobs: 10 | goreleaser: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@v4 15 | with: 16 | fetch-depth: 0 17 | - name: Set up Go 18 | uses: actions/setup-go@v5 19 | with: 20 | go-version: 'oldstable' 21 | - name: Run GoReleaser 22 | uses: goreleaser/goreleaser-action@v6 23 | with: 24 | distribution: goreleaser 25 | version: latest 26 | args: release --clean 27 | env: 28 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 29 | -------------------------------------------------------------------------------- /.github/workflows/run-tests.yml: -------------------------------------------------------------------------------- 1 | name: run-tests 2 | 3 | on: 4 | workflow_call: 5 | outputs: 6 | pr_number: 7 | description: "The PR number" 8 | value: ${{ jobs.test.outputs.pr_number }} 9 | push: 10 | branches: [ main ] 11 | pull_request: 12 | 13 | jobs: 14 | test: 15 | runs-on: ubuntu-latest 16 | strategy: 17 | matrix: 18 | version: [ '1.24', '1.23' ] 19 | name: Go ${{ matrix.version }} 20 | outputs: 21 | pr_number: ${{ github.event.number }} 22 | steps: 23 | - uses: actions/setup-go@v5 24 | with: 25 | go-version: ${{ matrix.version }} 26 | - uses: actions/checkout@v4 27 | - run: GOTOOLCHAIN=local make test 28 | call-dependabot-pr-workflow: 29 | needs: test 30 | if: ${{ success() && github.actor == 'dependabot[bot]' }} 31 | uses: cloudfoundry/brokerapi/.github/workflows/dependabot-test.yml@main 32 | with: 33 | pr_number: ${{ github.event.number }} 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | vendor/ 2 | .idea 3 | *.coverprofile 4 | -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | builds: 3 | - skip: true 4 | archives: [] 5 | changelog: 6 | sort: asc 7 | groups: 8 | - title: 'Breaking Changes' 9 | regexp: "^.*feat![(\\w)]*:+.*$" 10 | order: 0 11 | - title: 'Features' 12 | regexp: "^.*feat[(\\w)]*:+.*$" 13 | order: 1 14 | - title: 'Bug fixes' 15 | regexp: "^.*fix[(\\w)]*:+.*$" 16 | order: 2 17 | - title: 'Dependency updates' 18 | regexp: "^.*(deps)[(\\w)]*:+.*$" 19 | order: 3 20 | - title: Others 21 | order: 999 22 | checksum: 23 | algorithm: sha1 24 | -------------------------------------------------------------------------------- /CODE-OF-CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in brokerapi project and our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at oss-coc@vmware.com. 63 | All complaints will be reviewed and investigated promptly and fairly. 64 | 65 | All community leaders are obligated to respect the privacy and security of the 66 | reporter of any incident. 67 | 68 | ## Enforcement Guidelines 69 | 70 | Community leaders will follow these Community Impact Guidelines in determining 71 | the consequences for any action they deem in violation of this Code of Conduct: 72 | 73 | ### 1. Correction 74 | 75 | **Community Impact**: Use of inappropriate language or other behavior deemed 76 | unprofessional or unwelcome in the community. 77 | 78 | **Consequence**: A private, written warning from community leaders, providing 79 | clarity around the nature of the violation and an explanation of why the 80 | behavior was inappropriate. A public apology may be requested. 81 | 82 | ### 2. Warning 83 | 84 | **Community Impact**: A violation through a single incident or series 85 | of actions. 86 | 87 | **Consequence**: A warning with consequences for continued behavior. No 88 | interaction with the people involved, including unsolicited interaction with 89 | those enforcing the Code of Conduct, for a specified period of time. This 90 | includes avoiding interactions in community spaces as well as external channels 91 | like social media. Violating these terms may lead to a temporary or 92 | permanent ban. 93 | 94 | ### 3. Temporary Ban 95 | 96 | **Community Impact**: A serious violation of community standards, including 97 | sustained inappropriate behavior. 98 | 99 | **Consequence**: A temporary ban from any sort of interaction or public 100 | communication with the community for a specified period of time. No public or 101 | private interaction with the people involved, including unsolicited interaction 102 | with those enforcing the Code of Conduct, is allowed during this period. 103 | Violating these terms may lead to a permanent ban. 104 | 105 | ### 4. Permanent Ban 106 | 107 | **Community Impact**: Demonstrating a pattern of violation of community 108 | standards, including sustained inappropriate behavior, harassment of an 109 | individual, or aggression toward or disparagement of classes of individuals. 110 | 111 | **Consequence**: A permanent ban from any sort of public interaction within 112 | the community. 113 | 114 | ## Attribution 115 | 116 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 117 | version 2.0, available at 118 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 119 | 120 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 121 | enforcement ladder](https://github.com/mozilla/diversity). 122 | 123 | [homepage]: https://www.contributor-covenant.org 124 | 125 | For answers to common questions about this code of conduct, see the FAQ at 126 | https://www.contributor-covenant.org/faq. Translations are available at 127 | https://www.contributor-covenant.org/translations. 128 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to brokerapi 2 | 3 | The brokerapi project team welcomes contributions from the community. Before you start working with brokerapi, please 4 | read our [Developer Certificate of Origin](https://cla.vmware.com/dco). All contributions to this repository must be 5 | signed as described on that page. Your signature certifies that you wrote the patch or have the right to pass it on 6 | as an open-source patch. 7 | 8 | ## Contribution Flow 9 | 10 | This is a rough outline of what a contributor's workflow looks like: 11 | 12 | - Create a topic branch from where you want to base your work 13 | - Make commits of logical units 14 | - Make sure your commit messages are in the proper format (see below) 15 | - Push your changes to a topic branch in your fork of the repository 16 | - Submit a pull request 17 | 18 | Example: 19 | 20 | ``` shell 21 | git remote add upstream https://github.com/vmware/@(project).git 22 | git checkout -b my-new-feature main 23 | git commit -a 24 | git push origin my-new-feature 25 | ``` 26 | 27 | ### Updating pull requests 28 | 29 | If your PR fails to pass CI or needs changes based on code review, you'll most likely want to squash these changes into 30 | existing commits. 31 | 32 | If your pull request contains a single commit or your changes are related to the most recent commit, you can simply 33 | amend the commit. 34 | 35 | Be sure to add a comment to the PR indicating your new changes are ready to review, as GitHub does not generate a 36 | notification when you git push. 37 | 38 | ### Formatting Commit Messages 39 | 40 | We follow the conventions on [Conventional Commits](https://www.conventionalcommits.org/) and 41 | [How to Write a Git Commit Message](http://chris.beams.io/posts/git-commit/). 42 | 43 | Be sure to include any related GitHub issue references in the commit message. See 44 | [GFM syntax](https://guides.github.com/features/mastering-markdown/#GitHub-flavored-markdown) for referencing issues 45 | and commits. 46 | -------------------------------------------------------------------------------- /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 {yyyy} {name of copyright owner} 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 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | ###### Help ################################################################### 2 | 3 | .DEFAULT_GOAL = help 4 | 5 | .PHONY: help 6 | 7 | help: ## list Makefile targets 8 | @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' 9 | 10 | ###### Targets ################################################################ 11 | 12 | test: version download fmt vet ginkgo ## Runs all build, static analysis, and test steps 13 | 14 | download: ## Download dependencies 15 | go mod download 16 | 17 | vet: ## Run static code analysis 18 | go vet ./... 19 | go run honnef.co/go/tools/cmd/staticcheck ./... 20 | 21 | ginkgo: ## Run tests using Ginkgo 22 | go run github.com/onsi/ginkgo/v2/ginkgo -r 23 | 24 | fmt: ## Checks that the code is formatted correctly 25 | @@if [ -n "$$(gofmt -s -e -l -d .)" ]; then \ 26 | echo "gofmt check failed: run 'gofmt -d -e -l -w .'"; \ 27 | exit 1; \ 28 | fi 29 | 30 | generate: ## Generates the fakes using counterfeiter 31 | go generate ./... 32 | 33 | version: ## Display the version of Go 34 | @@go version 35 | -------------------------------------------------------------------------------- /NOTICE: -------------------------------------------------------------------------------- 1 | brokerapi 2 | 3 | Copyright (c) 2014-2018 Pivotal Software, Inc. All Rights Reserved. 4 | 5 | This product is licensed to you under the Apache License, Version 2.0 (the "License"). 6 | You may not use this product except in compliance with the License. 7 | 8 | This product may include a number of subcomponents with separate copyright notices 9 | and license terms. Your use of these subcomponents is subject to the terms and 10 | conditions of the subcomponent's license, as noted in the LICENSE file. 11 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # brokerapi 2 | 3 | [![test](https://github.com/cloudfoundry/brokerapi/actions/workflows/run-tests.yml/badge.svg?branch=main)](https://github.com/cloudfoundry/brokerapi/actions/workflows/run-tests.yml?query=branch%3Amain) 4 | 5 | https://github.com/cloudfoundry/brokerapi/actions/workflows/run-tests/badge.svg?query=branch%3Amain 6 | 7 | A Go package for building [V2 Open Service Broker API](https://github.com/openservicebrokerapi/servicebroker/) compliant Service Brokers. 8 | 9 | ## [Docs](https://godoc.org/code.cloudfoundry.org/brokerapi/v13) 10 | 11 | ## Dependencies 12 | 13 | - Go 14 | - GNU Make 3.81 15 | 16 | ## Contributing 17 | 18 | We appreciate and welcome open source contribution. We will try to review the changes as soon as we can. 19 | 20 | ## Usage 21 | 22 | `brokerapi` defines a 23 | [`ServiceBroker`](https://godoc.org/code.cloudfoundry.org/brokerapi/v13/domain#ServiceBroker/domain#ServiceBroker) 24 | interface. Pass an implementation of this to 25 | [`brokerapi.New`](https://godoc.org/code.cloudfoundry.org/brokerapi/v13#New), 26 | which returns an `http.Handler` that you can use to serve handle HTTP requests. 27 | 28 | ## Error types 29 | 30 | `brokerapi` defines a handful of error types in `service_broker.go` for some 31 | common error cases that your service broker may encounter. Return these from 32 | your `ServiceBroker` methods where appropriate, and `brokerapi` will do the 33 | "right thing" (™), and give Cloud Foundry an appropriate status code, as per 34 | the [Service Broker API 35 | specification](https://docs.cloudfoundry.org/services/api.html). 36 | 37 | ### Custom Errors 38 | 39 | `NewFailureResponse()` allows you to return a custom error from any of the 40 | `ServiceBroker` interface methods which return an error. Within this you must 41 | define an error, a HTTP response status code and a logging key. You can also 42 | use the `NewFailureResponseBuilder()` to add a custom `Error:` value in the 43 | response, or indicate that the broker should return an empty response rather 44 | than the error message. 45 | 46 | ## Request Context 47 | 48 | When provisioning a service `brokerapi` validates the `service_id` and `plan_id` 49 | in the request, attaching the found instances to the request Context. These 50 | values can be retrieved in a `brokerapi.ServiceBroker` implementation using 51 | utility methods `RetrieveServiceFromContext` and `RetrieveServicePlanFromContext` 52 | as shown below. 53 | 54 | ```go 55 | func (sb *ServiceBrokerImplementation) Provision(ctx context.Context, 56 | instanceID string, details brokerapi.ProvisionDetails, asyncAllowed bool) { 57 | 58 | service := brokerapi.RetrieveServiceFromContext(ctx) 59 | if service == nil { 60 | // Lookup service 61 | } 62 | 63 | // [..] 64 | } 65 | ``` 66 | 67 | ## Originating Identity 68 | 69 | The request context for every request contains the unparsed 70 | `X-Broker-API-Originating-Identity` header under the key 71 | `originatingIdentity`. More details on how the Open Service Broker API 72 | manages request originating identity is available 73 | [here](https://github.com/openservicebrokerapi/servicebroker/blob/master/spec.md#originating-identity). 74 | 75 | ## Request Identity 76 | 77 | The request context for every request contains the unparsed 78 | `X-Broker-API-Request-Identity` header under the key 79 | `requestIdentity`. More details on how the Open Service Broker API 80 | manages request originating identity is available 81 | [here](https://github.com/openservicebrokerapi/servicebroker/blob/master/spec.md#request-identity). 82 | 83 | ## Example Service Broker 84 | 85 | You can see the 86 | [cf-redis](https://github.com/pivotal-cf/cf-redis-broker/blob/2f0e9a8ebb1012a9be74bbef2d411b0b3b60352f/broker/broker.go) 87 | service broker uses the BrokerAPI package to create a service broker for Redis. 88 | 89 | ## Releasing 90 | 91 | Releasing steps can be found [here](https://github.com/cloudfoundry/brokerapi/wiki/Releasing-new-BrokerAPI-major-version) 92 | 93 | -------------------------------------------------------------------------------- /api.go: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2015-Present Pivotal Software, Inc. All rights reserved. 2 | 3 | // This program and the accompanying materials are made available under 4 | // the terms of the under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | 16 | package brokerapi 17 | 18 | import ( 19 | "log/slog" 20 | "net/http" 21 | 22 | "code.cloudfoundry.org/brokerapi/v13/internal/middleware" 23 | 24 | "code.cloudfoundry.org/brokerapi/v13/auth" 25 | "code.cloudfoundry.org/brokerapi/v13/domain" 26 | "code.cloudfoundry.org/brokerapi/v13/handlers" 27 | "code.cloudfoundry.org/brokerapi/v13/middlewares" 28 | ) 29 | 30 | type BrokerCredentials struct { 31 | Username string 32 | Password string 33 | } 34 | 35 | func New(serviceBroker domain.ServiceBroker, logger *slog.Logger, brokerCredentials BrokerCredentials, opts ...Option) http.Handler { 36 | return NewWithOptions(serviceBroker, logger, append([]Option{WithBrokerCredentials(brokerCredentials)}, opts...)...) 37 | } 38 | 39 | func NewWithOptions(serviceBroker domain.ServiceBroker, logger *slog.Logger, opts ...Option) http.Handler { 40 | var cfg config 41 | WithOptions(opts...)(&cfg) 42 | 43 | mw := append(append(cfg.authMiddleware, defaultMiddleware(logger)...), cfg.additionalMiddleware...) 44 | r := router(serviceBroker, logger) 45 | 46 | return middleware.Use(r, mw...) 47 | } 48 | 49 | func NewWithCustomAuth(serviceBroker domain.ServiceBroker, logger *slog.Logger, authMiddleware func(handler http.Handler) http.Handler) http.Handler { 50 | return NewWithOptions(serviceBroker, logger, WithCustomAuth(authMiddleware)) 51 | } 52 | 53 | type config struct { 54 | authMiddleware []func(http.Handler) http.Handler 55 | additionalMiddleware []func(http.Handler) http.Handler 56 | } 57 | 58 | type Option func(*config) 59 | 60 | func WithBrokerCredentials(brokerCredentials BrokerCredentials) Option { 61 | return func(c *config) { 62 | c.authMiddleware = append(c.authMiddleware, auth.NewWrapper(brokerCredentials.Username, brokerCredentials.Password).Wrap) 63 | } 64 | } 65 | 66 | // WithCustomAuth adds the specified middleware *before* any other middleware. 67 | // Despite the name, any middleware can be added whether nor not it has anything to do with authentication. 68 | // But `WithAdditionalMiddleware()` may be a better choice if the middleware is not related to authentication. 69 | // Can be called multiple times. 70 | func WithCustomAuth(authMiddleware func(handler http.Handler) http.Handler) Option { 71 | return func(c *config) { 72 | c.authMiddleware = append(c.authMiddleware, authMiddleware) 73 | } 74 | } 75 | 76 | // WithAdditionalMiddleware adds the specified middleware *after* the default middleware. 77 | // Can be called multiple times. 78 | func WithAdditionalMiddleware(m func(http.Handler) http.Handler) Option { 79 | return func(c *config) { 80 | c.additionalMiddleware = append(c.additionalMiddleware, m) 81 | } 82 | } 83 | 84 | func WithOptions(opts ...Option) Option { 85 | return func(c *config) { 86 | for _, o := range opts { 87 | o(c) 88 | } 89 | } 90 | } 91 | 92 | func router(serviceBroker ServiceBroker, logger *slog.Logger) http.Handler { 93 | apiHandler := handlers.NewApiHandler(serviceBroker, logger) 94 | r := http.NewServeMux() 95 | r.HandleFunc("GET /v2/catalog", apiHandler.Catalog) 96 | 97 | r.HandleFunc("PUT /v2/service_instances/{instance_id}", apiHandler.Provision) 98 | r.HandleFunc("GET /v2/service_instances/{instance_id}", apiHandler.GetInstance) 99 | r.HandleFunc("PATCH /v2/service_instances/{instance_id}", apiHandler.Update) 100 | r.HandleFunc("DELETE /v2/service_instances/{instance_id}", apiHandler.Deprovision) 101 | 102 | r.HandleFunc("GET /v2/service_instances/{instance_id}/last_operation", apiHandler.LastOperation) 103 | 104 | r.HandleFunc("PUT /v2/service_instances/{instance_id}/service_bindings/{binding_id}", apiHandler.Bind) 105 | r.HandleFunc("GET /v2/service_instances/{instance_id}/service_bindings/{binding_id}", apiHandler.GetBinding) 106 | r.HandleFunc("DELETE /v2/service_instances/{instance_id}/service_bindings/{binding_id}", apiHandler.Unbind) 107 | 108 | r.HandleFunc("GET /v2/service_instances/{instance_id}/service_bindings/{binding_id}/last_operation", apiHandler.LastBindingOperation) 109 | 110 | return r 111 | } 112 | 113 | func defaultMiddleware(logger *slog.Logger) []func(http.Handler) http.Handler { 114 | return []func(http.Handler) http.Handler{ 115 | middlewares.APIVersionMiddleware{Logger: logger}.ValidateAPIVersionHdr, 116 | middlewares.AddCorrelationIDToContext, 117 | middlewares.AddOriginatingIdentityToContext, 118 | middlewares.AddInfoLocationToContext, 119 | middlewares.AddRequestIdentityToContext, 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /api_suite_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2015-Present Pivotal Software, Inc. All rights reserved. 2 | 3 | // This program and the accompanying materials are made available under 4 | // the terms of the under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | 16 | package brokerapi_test 17 | 18 | import ( 19 | "embed" 20 | "io" 21 | "path" 22 | "testing" 23 | 24 | "github.com/google/uuid" 25 | . "github.com/onsi/ginkgo/v2" 26 | . "github.com/onsi/gomega" 27 | ) 28 | 29 | func TestAPI(t *testing.T) { 30 | RegisterFailHandler(Fail) 31 | RunSpecs(t, "API Suite") 32 | } 33 | 34 | //go:embed fixtures/*.json 35 | var fixtures embed.FS 36 | 37 | func fixture(name string) string { 38 | GinkgoHelper() 39 | 40 | fh := must(fixtures.Open(path.Join("fixtures", name))) 41 | defer fh.Close() 42 | return string(must(io.ReadAll(fh))) 43 | } 44 | 45 | func uniqueID() string { 46 | return uuid.NewString() 47 | } 48 | 49 | func uniqueInstanceID() string { 50 | return uniqueID() 51 | } 52 | 53 | func uniqueBindingID() string { 54 | return uniqueID() 55 | } 56 | -------------------------------------------------------------------------------- /auth/auth.go: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2015-Present Pivotal Software, Inc. All rights reserved. 2 | 3 | // This program and the accompanying materials are made available under 4 | // the terms of the under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | 16 | package auth 17 | 18 | import ( 19 | "crypto/sha256" 20 | "crypto/subtle" 21 | "net/http" 22 | ) 23 | 24 | type Wrapper struct { 25 | credentials []Credential 26 | } 27 | 28 | type Credential struct { 29 | username []byte 30 | password []byte 31 | } 32 | 33 | func NewWrapperMultiple(users map[string]string) *Wrapper { 34 | var cs []Credential 35 | for k, v := range users { 36 | u := sha256.Sum256([]byte(k)) 37 | p := sha256.Sum256([]byte(v)) 38 | cs = append(cs, Credential{username: u[:], password: p[:]}) 39 | } 40 | return &Wrapper{credentials: cs} 41 | } 42 | 43 | func NewWrapper(username, password string) *Wrapper { 44 | return NewWrapperMultiple(map[string]string{username: password}) 45 | } 46 | 47 | const notAuthorized = "Not Authorized" 48 | 49 | func (wrapper *Wrapper) Wrap(handler http.Handler) http.Handler { 50 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 51 | if !authorized(wrapper, r) { 52 | http.Error(w, notAuthorized, http.StatusUnauthorized) 53 | return 54 | } 55 | 56 | handler.ServeHTTP(w, r) 57 | }) 58 | } 59 | 60 | func (wrapper *Wrapper) WrapFunc(handlerFunc http.HandlerFunc) http.HandlerFunc { 61 | return func(w http.ResponseWriter, r *http.Request) { 62 | if !authorized(wrapper, r) { 63 | http.Error(w, notAuthorized, http.StatusUnauthorized) 64 | return 65 | } 66 | 67 | handlerFunc(w, r) 68 | } 69 | } 70 | 71 | func authorized(wrapper *Wrapper, r *http.Request) bool { 72 | username, password, isOk := r.BasicAuth() 73 | if isOk { 74 | u := sha256.Sum256([]byte(username)) 75 | p := sha256.Sum256([]byte(password)) 76 | for _, c := range wrapper.credentials { 77 | if c.isAuthorized(u, p) { 78 | return true 79 | } 80 | } 81 | } 82 | return false 83 | } 84 | 85 | func (c Credential) isAuthorized(uChecksum [32]byte, pChecksum [32]byte) bool { 86 | return subtle.ConstantTimeCompare(c.username, uChecksum[:]) == 1 && 87 | subtle.ConstantTimeCompare(c.password, pChecksum[:]) == 1 88 | } 89 | -------------------------------------------------------------------------------- /auth/auth_suite_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2015-Present Pivotal Software, Inc. All rights reserved. 2 | 3 | // This program and the accompanying materials are made available under 4 | // the terms of the under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | 16 | package auth_test 17 | 18 | import ( 19 | "testing" 20 | 21 | . "github.com/onsi/ginkgo/v2" 22 | . "github.com/onsi/gomega" 23 | ) 24 | 25 | func TestAuth(t *testing.T) { 26 | RegisterFailHandler(Fail) 27 | RunSpecs(t, "Auth Suite") 28 | } 29 | -------------------------------------------------------------------------------- /auth/auth_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2015-Present Pivotal Software, Inc. All rights reserved. 2 | 3 | // This program and the accompanying materials are made available under 4 | // the terms of the under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | 16 | package auth_test 17 | 18 | import ( 19 | "net/http" 20 | "net/http/httptest" 21 | 22 | . "github.com/onsi/ginkgo/v2" 23 | . "github.com/onsi/gomega" 24 | 25 | "code.cloudfoundry.org/brokerapi/v13/auth" 26 | ) 27 | 28 | var _ = Describe("Auth Wrapper", func() { 29 | var ( 30 | username string 31 | password string 32 | httpRecorder *httptest.ResponseRecorder 33 | ) 34 | 35 | newRequest := func(username, password string) *http.Request { 36 | request, err := http.NewRequest("GET", "", nil) 37 | Expect(err).NotTo(HaveOccurred()) 38 | request.SetBasicAuth(username, password) 39 | return request 40 | } 41 | 42 | BeforeEach(func() { 43 | username = "username" 44 | password = "password" 45 | httpRecorder = httptest.NewRecorder() 46 | }) 47 | 48 | Describe("wrapped handler", func() { 49 | var wrappedHandler http.Handler 50 | 51 | BeforeEach(func() { 52 | handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 53 | w.WriteHeader(http.StatusCreated) 54 | }) 55 | wrappedHandler = auth.NewWrapper(username, password).Wrap(handler) 56 | }) 57 | 58 | It("works when the credentials are correct", func() { 59 | request := newRequest(username, password) 60 | wrappedHandler.ServeHTTP(httpRecorder, request) 61 | Expect(httpRecorder.Code).To(Equal(http.StatusCreated)) 62 | }) 63 | 64 | It("fails when the username is empty", func() { 65 | request := newRequest("", password) 66 | wrappedHandler.ServeHTTP(httpRecorder, request) 67 | Expect(httpRecorder.Code).To(Equal(http.StatusUnauthorized)) 68 | }) 69 | 70 | It("fails when the password is empty", func() { 71 | request := newRequest(username, "") 72 | wrappedHandler.ServeHTTP(httpRecorder, request) 73 | Expect(httpRecorder.Code).To(Equal(http.StatusUnauthorized)) 74 | }) 75 | 76 | It("fails when the credentials are wrong", func() { 77 | request := newRequest("thats", "apar") 78 | wrappedHandler.ServeHTTP(httpRecorder, request) 79 | Expect(httpRecorder.Code).To(Equal(http.StatusUnauthorized)) 80 | }) 81 | }) 82 | 83 | Describe("wrapped multiple handler", func() { 84 | var ( 85 | username2 string 86 | password2 string 87 | credentials map[string]string 88 | wrappedHandler http.Handler 89 | ) 90 | BeforeEach(func() { 91 | username2 = "username2" 92 | password2 = "password2" 93 | credentials = make(map[string]string) 94 | credentials[username] = password 95 | credentials[username2] = password2 96 | }) 97 | 98 | BeforeEach(func() { 99 | handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 100 | w.WriteHeader(http.StatusCreated) 101 | }) 102 | wrappedHandler = auth.NewWrapperMultiple(credentials).Wrap(handler) 103 | }) 104 | 105 | It("works when the credentials are correct", func() { 106 | request := newRequest(username, password) 107 | wrappedHandler.ServeHTTP(httpRecorder, request) 108 | Expect(httpRecorder.Code).To(Equal(http.StatusCreated)) 109 | 110 | request = newRequest(username2, password2) 111 | wrappedHandler.ServeHTTP(httpRecorder, request) 112 | Expect(httpRecorder.Code).To(Equal(http.StatusCreated)) 113 | }) 114 | 115 | It("fails when the username is empty", func() { 116 | request := newRequest("", password) 117 | wrappedHandler.ServeHTTP(httpRecorder, request) 118 | Expect(httpRecorder.Code).To(Equal(http.StatusUnauthorized)) 119 | }) 120 | 121 | It("fails when the password is empty", func() { 122 | request := newRequest(username, "") 123 | wrappedHandler.ServeHTTP(httpRecorder, request) 124 | Expect(httpRecorder.Code).To(Equal(http.StatusUnauthorized)) 125 | }) 126 | 127 | It("fails when the credentials are wrong", func() { 128 | request := newRequest("thats", "apar") 129 | wrappedHandler.ServeHTTP(httpRecorder, request) 130 | Expect(httpRecorder.Code).To(Equal(http.StatusUnauthorized)) 131 | }) 132 | }) 133 | 134 | Describe("wrapped handlerFunc", func() { 135 | var wrappedHandlerFunc http.HandlerFunc 136 | 137 | BeforeEach(func() { 138 | handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 139 | w.WriteHeader(http.StatusCreated) 140 | }) 141 | wrappedHandlerFunc = auth.NewWrapper(username, password).WrapFunc(handler) 142 | }) 143 | 144 | It("works when the credentials are correct", func() { 145 | request := newRequest(username, password) 146 | wrappedHandlerFunc.ServeHTTP(httpRecorder, request) 147 | Expect(httpRecorder.Code).To(Equal(http.StatusCreated)) 148 | }) 149 | 150 | It("fails when the username is empty", func() { 151 | request := newRequest("", password) 152 | wrappedHandlerFunc.ServeHTTP(httpRecorder, request) 153 | Expect(httpRecorder.Code).To(Equal(http.StatusUnauthorized)) 154 | }) 155 | 156 | It("fails when the password is empty", func() { 157 | request := newRequest(username, "") 158 | wrappedHandlerFunc.ServeHTTP(httpRecorder, request) 159 | Expect(httpRecorder.Code).To(Equal(http.StatusUnauthorized)) 160 | }) 161 | 162 | It("fails when the credentials are wrong", func() { 163 | request := newRequest("thats", "apar") 164 | wrappedHandlerFunc.ServeHTTP(httpRecorder, request) 165 | Expect(httpRecorder.Code).To(Equal(http.StatusUnauthorized)) 166 | }) 167 | }) 168 | }) 169 | -------------------------------------------------------------------------------- /catalog.go: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2015-Present Pivotal Software, Inc. All rights reserved. 2 | 3 | // This program and the accompanying materials are made available under 4 | // the terms of the under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | 16 | package brokerapi 17 | 18 | import ( 19 | "reflect" 20 | 21 | "code.cloudfoundry.org/brokerapi/v13/domain" 22 | ) 23 | 24 | // Deprecated: Use code.cloudfoundry.org/brokerapi/v13/domain 25 | type Service = domain.Service 26 | 27 | // Deprecated: Use code.cloudfoundry.org/brokerapi/v13/domain 28 | type ServiceDashboardClient = domain.ServiceDashboardClient 29 | 30 | // Deprecated: Use code.cloudfoundry.org/brokerapi/v13/domain 31 | type ServicePlan = domain.ServicePlan 32 | 33 | // Deprecated: Use code.cloudfoundry.org/brokerapi/v13/domain 34 | type ServiceSchemas = domain.ServiceSchemas 35 | 36 | // Deprecated: Use code.cloudfoundry.org/brokerapi/v13/domain 37 | type ServiceInstanceSchema = domain.ServiceInstanceSchema 38 | 39 | // Deprecated: Use code.cloudfoundry.org/brokerapi/v13/domain 40 | type ServiceBindingSchema = domain.ServiceBindingSchema 41 | 42 | // Deprecated: Use code.cloudfoundry.org/brokerapi/v13/domain 43 | type Schema = domain.Schema 44 | 45 | // Deprecated: Use code.cloudfoundry.org/brokerapi/v13/domain 46 | type ServicePlanMetadata = domain.ServicePlanMetadata 47 | 48 | // Deprecated: Use code.cloudfoundry.org/brokerapi/v13/domain 49 | type ServicePlanCost = domain.ServicePlanCost 50 | 51 | // Deprecated: Use code.cloudfoundry.org/brokerapi/v13/domain 52 | type ServiceMetadata = domain.ServiceMetadata 53 | 54 | // Deprecated: Use code.cloudfoundry.org/brokerapi/v13/domain 55 | func FreeValue(v bool) *bool { 56 | return domain.FreeValue(v) 57 | } 58 | 59 | // Deprecated: Use code.cloudfoundry.org/brokerapi/v13/domain 60 | func BindableValue(v bool) *bool { 61 | return domain.BindableValue(v) 62 | } 63 | 64 | // Deprecated: Use code.cloudfoundry.org/brokerapi/v13/domain 65 | type RequiredPermission = domain.RequiredPermission 66 | 67 | // Deprecated: Use code.cloudfoundry.org/brokerapi/v13/domain 68 | const ( 69 | PermissionRouteForwarding = domain.PermissionRouteForwarding 70 | PermissionSyslogDrain = domain.PermissionSyslogDrain 71 | PermissionVolumeMount = domain.PermissionVolumeMount 72 | ) 73 | 74 | // Deprecated: Use code.cloudfoundry.org/brokerapi/v13/domain 75 | func GetJsonNames(s reflect.Value) (res []string) { 76 | return domain.GetJsonNames(s) 77 | } 78 | -------------------------------------------------------------------------------- /context_utils.go: -------------------------------------------------------------------------------- 1 | package brokerapi 2 | 3 | import ( 4 | "context" 5 | 6 | "code.cloudfoundry.org/brokerapi/v13/utils" 7 | ) 8 | 9 | func AddServiceToContext(ctx context.Context, service *Service) context.Context { 10 | return utils.AddServiceToContext(ctx, service) 11 | } 12 | 13 | func RetrieveServiceFromContext(ctx context.Context) *Service { 14 | return utils.RetrieveServiceFromContext(ctx) 15 | } 16 | 17 | func AddServicePlanToContext(ctx context.Context, plan *ServicePlan) context.Context { 18 | return utils.AddServicePlanToContext(ctx, plan) 19 | } 20 | 21 | func RetrieveServicePlanFromContext(ctx context.Context) *ServicePlan { 22 | return utils.RetrieveServicePlanFromContext(ctx) 23 | } 24 | -------------------------------------------------------------------------------- /context_utils_test.go: -------------------------------------------------------------------------------- 1 | package brokerapi_test 2 | 3 | import ( 4 | "context" 5 | 6 | . "github.com/onsi/ginkgo/v2" 7 | . "github.com/onsi/gomega" 8 | 9 | "code.cloudfoundry.org/brokerapi/v13" 10 | ) 11 | 12 | var _ = Describe("Context Utilities", func() { 13 | 14 | type testContextKey string 15 | 16 | var ( 17 | ctx context.Context 18 | contextValidatorKey testContextKey 19 | contextValidatorValue string 20 | ) 21 | 22 | BeforeEach(func() { 23 | contextValidatorKey = "context-utilities-test" 24 | contextValidatorValue = "original" 25 | ctx = context.Background() 26 | ctx = context.WithValue(ctx, contextValidatorKey, contextValidatorValue) 27 | }) 28 | 29 | Describe("Service Context", func() { 30 | Context("when the service is nil", func() { 31 | It("returns the original context", func() { 32 | ctx = brokerapi.AddServiceToContext(ctx, nil) 33 | Expect(ctx.Err()).To(BeZero()) 34 | Expect(brokerapi.RetrieveServiceFromContext(ctx)).To(BeZero()) 35 | Expect(ctx.Value(contextValidatorKey).(string)).To(Equal(contextValidatorValue)) 36 | }) 37 | }) 38 | 39 | Context("when the service is valid", func() { 40 | It("sets and receives the service in the context", func() { 41 | service := &brokerapi.Service{ 42 | ID: "9A3095D7-ED3C-45FA-BC9F-592820628723", 43 | Name: "Test Service", 44 | } 45 | ctx = brokerapi.AddServiceToContext(ctx, service) 46 | Expect(ctx.Err()).To(BeZero()) 47 | Expect(ctx.Value(contextValidatorKey).(string)).To(Equal(contextValidatorValue)) 48 | Expect(brokerapi.RetrieveServiceFromContext(ctx).ID).To(Equal(service.ID)) 49 | Expect(brokerapi.RetrieveServiceFromContext(ctx).Name).To(Equal(service.Name)) 50 | Expect(brokerapi.RetrieveServiceFromContext(ctx).Metadata).To(BeZero()) 51 | }) 52 | }) 53 | }) 54 | 55 | Describe("Plan Context", func() { 56 | Context("when the service plan is nil", func() { 57 | It("returns the original context", func() { 58 | ctx = brokerapi.AddServicePlanToContext(ctx, nil) 59 | Expect(ctx.Err()).To(BeZero()) 60 | Expect(brokerapi.RetrieveServicePlanFromContext(ctx)).To(BeZero()) 61 | Expect(ctx.Value(contextValidatorKey).(string)).To(Equal(contextValidatorValue)) 62 | }) 63 | }) 64 | 65 | Context("when the service plan is valid", func() { 66 | It("sets and retrieves the service plan in the context", func() { 67 | plan := &brokerapi.ServicePlan{ 68 | ID: "AC257573-8C62-4B1A-AC34-ECA3863F50EC", 69 | } 70 | ctx = brokerapi.AddServicePlanToContext(ctx, plan) 71 | Expect(ctx.Err()).To(BeZero()) 72 | Expect(ctx.Value(contextValidatorKey).(string)).To(Equal(contextValidatorValue)) 73 | Expect(brokerapi.RetrieveServicePlanFromContext(ctx).ID).To(Equal(plan.ID)) 74 | }) 75 | }) 76 | }) 77 | }) 78 | -------------------------------------------------------------------------------- /create_version_dir.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | version="${1:?"Usage: create_version_dir.sh "}" 6 | 7 | if [[ ! "$version" =~ ^v ]]; then 8 | version="v$version" 9 | fi 10 | 11 | go_files=$(find . ! -path "*/vendor/*" ! -path "*/fakes/*" ! -path "*/tools/*" ! -path "*/v[0-9]*/*" ! -name "*_test.go" -name "*.go") 12 | for f in $go_files ; do 13 | mkdir -p "$version/$(dirname $f)" 14 | cp $f $version/$(dirname $f) 15 | done 16 | -------------------------------------------------------------------------------- /domain/apiresponses/apiresponses_suite_test.go: -------------------------------------------------------------------------------- 1 | package apiresponses_test 2 | 3 | import ( 4 | "testing" 5 | 6 | . "github.com/onsi/ginkgo/v2" 7 | . "github.com/onsi/gomega" 8 | ) 9 | 10 | func TestApiresponses(t *testing.T) { 11 | RegisterFailHandler(Fail) 12 | RunSpecs(t, "API Responses Suite") 13 | } 14 | -------------------------------------------------------------------------------- /domain/apiresponses/errors.go: -------------------------------------------------------------------------------- 1 | package apiresponses 2 | 3 | import ( 4 | "errors" 5 | "net/http" 6 | ) 7 | 8 | const ( 9 | instanceExistsMsg = "instance already exists" 10 | instanceDoesntExistMsg = "instance does not exist" 11 | instanceNotFoundMsg = "instance cannot be fetched" 12 | serviceLimitReachedMsg = "instance limit for this service has been reached" 13 | servicePlanQuotaExceededMsg = "The quota for this service plan has been exceeded. Please contact your Operator for help." 14 | serviceQuotaExceededMsg = "The quota for this service has been exceeded. Please contact your Operator for help." 15 | bindingExistsMsg = "binding already exists" 16 | bindingDoesntExistMsg = "binding does not exist" 17 | bindingNotFoundMsg = "binding cannot be fetched" 18 | asyncRequiredMsg = "This service plan requires client support for asynchronous service operations." 19 | planChangeUnsupportedMsg = "The requested plan migration cannot be performed" 20 | rawInvalidParamsMsg = "The format of the parameters is not valid JSON" 21 | appGuidMissingMsg = "app_guid is a required field but was not provided" 22 | concurrentInstanceAccessMsg = "instance is being updated and cannot be retrieved" 23 | maintenanceInfoConflictMsg = "passed maintenance_info does not match the catalog maintenance_info" 24 | maintenanceInfoNilConflictMsg = "maintenance_info was passed, but the broker catalog contains no maintenance_info" 25 | 26 | instanceLimitReachedErrorKey = "instance-limit-reached" 27 | instanceAlreadyExistsErrorKey = "instance-already-exists" 28 | bindingAlreadyExistsErrorKey = "binding-already-exists" 29 | instanceMissingErrorKey = "instance-missing" 30 | instanceNotFoundErrorKey = "instance-not-found" 31 | bindingMissingErrorKey = "binding-missing" 32 | bindingNotFoundErrorKey = "binding-not-found" 33 | asyncRequiredKey = "async-required" 34 | planChangeNotSupportedKey = "plan-change-not-supported" 35 | invalidRawParamsKey = "invalid-raw-params" 36 | appGuidNotProvidedErrorKey = "app-guid-not-provided" 37 | concurrentAccessKey = "get-instance-during-update" 38 | maintenanceInfoConflictKey = "maintenance-info-conflict" 39 | ) 40 | 41 | var ( 42 | ErrInstanceAlreadyExists = NewFailureResponseBuilder( 43 | errors.New(instanceExistsMsg), http.StatusConflict, instanceAlreadyExistsErrorKey, 44 | ).WithEmptyResponse().Build() 45 | 46 | ErrInstanceDoesNotExist = NewFailureResponseBuilder( 47 | errors.New(instanceDoesntExistMsg), http.StatusGone, instanceMissingErrorKey, 48 | ).WithEmptyResponse().Build() 49 | 50 | ErrInstanceNotFound = NewFailureResponseBuilder( 51 | errors.New(instanceDoesntExistMsg), http.StatusNotFound, instanceNotFoundErrorKey, 52 | ).WithEmptyResponse().Build() 53 | 54 | ErrInstanceLimitMet = NewFailureResponse( 55 | errors.New(serviceLimitReachedMsg), http.StatusInternalServerError, instanceLimitReachedErrorKey, 56 | ) 57 | 58 | ErrBindingAlreadyExists = NewFailureResponse( 59 | errors.New(bindingExistsMsg), http.StatusConflict, bindingAlreadyExistsErrorKey, 60 | ) 61 | 62 | ErrBindingDoesNotExist = NewFailureResponseBuilder( 63 | errors.New(bindingDoesntExistMsg), http.StatusGone, bindingMissingErrorKey, 64 | ).WithEmptyResponse().Build() 65 | 66 | ErrBindingNotFound = NewFailureResponseBuilder( 67 | errors.New(bindingNotFoundMsg), http.StatusNotFound, bindingNotFoundErrorKey, 68 | ).WithEmptyResponse().Build() 69 | 70 | ErrAsyncRequired = NewFailureResponseBuilder( 71 | errors.New(asyncRequiredMsg), http.StatusUnprocessableEntity, asyncRequiredKey, 72 | ).WithErrorKey("AsyncRequired").Build() 73 | 74 | ErrPlanChangeNotSupported = NewFailureResponseBuilder( 75 | errors.New(planChangeUnsupportedMsg), http.StatusUnprocessableEntity, planChangeNotSupportedKey, 76 | ).WithErrorKey("PlanChangeNotSupported").Build() 77 | 78 | ErrRawParamsInvalid = NewFailureResponse( 79 | errors.New(rawInvalidParamsMsg), http.StatusUnprocessableEntity, invalidRawParamsKey, 80 | ) 81 | 82 | ErrAppGuidNotProvided = NewFailureResponse( 83 | errors.New(appGuidMissingMsg), http.StatusUnprocessableEntity, appGuidNotProvidedErrorKey, 84 | ) 85 | 86 | ErrPlanQuotaExceeded = errors.New(servicePlanQuotaExceededMsg) 87 | ErrServiceQuotaExceeded = errors.New(serviceQuotaExceededMsg) 88 | 89 | ErrConcurrentInstanceAccess = NewFailureResponseBuilder( 90 | errors.New(concurrentInstanceAccessMsg), http.StatusUnprocessableEntity, concurrentAccessKey, 91 | ).WithErrorKey("ConcurrencyError").Build() 92 | 93 | ErrMaintenanceInfoConflict = NewFailureResponseBuilder( 94 | errors.New(maintenanceInfoConflictMsg), http.StatusUnprocessableEntity, maintenanceInfoConflictKey, 95 | ).WithErrorKey("MaintenanceInfoConflict").Build() 96 | 97 | ErrMaintenanceInfoNilConflict = NewFailureResponseBuilder( 98 | errors.New(maintenanceInfoNilConflictMsg), http.StatusUnprocessableEntity, maintenanceInfoConflictKey, 99 | ).WithErrorKey("MaintenanceInfoConflict").Build() 100 | ) 101 | -------------------------------------------------------------------------------- /domain/apiresponses/failure_responses.go: -------------------------------------------------------------------------------- 1 | package apiresponses 2 | 3 | import ( 4 | "fmt" 5 | "log/slog" 6 | "net/http" 7 | ) 8 | 9 | // FailureResponse can be returned from any of the `ServiceBroker` interface methods 10 | // which allow an error to be returned. Doing so will provide greater control over 11 | // the HTTP response. 12 | type FailureResponse struct { 13 | error 14 | statusCode int 15 | loggerAction string 16 | emptyResponse bool 17 | errorKey string 18 | } 19 | 20 | // NewFailureResponse returns an error of type FailureResponse. 21 | // err will by default be used as both a logging message and HTTP response description. 22 | // statusCode is the HTTP status code to be returned, must be 4xx or 5xx 23 | // loggerAction is a short description which will be used as the action if the error is logged. 24 | func NewFailureResponse(err error, statusCode int, loggerAction string) error { 25 | return &FailureResponse{ 26 | error: err, 27 | statusCode: statusCode, 28 | loggerAction: loggerAction, 29 | } 30 | } 31 | 32 | // ErrorResponse returns an any which will be JSON encoded and form the body 33 | // of the HTTP response 34 | func (f *FailureResponse) ErrorResponse() any { 35 | if f.emptyResponse { 36 | return EmptyResponse{} 37 | } 38 | 39 | return ErrorResponse{ 40 | Description: f.error.Error(), 41 | Error: f.errorKey, 42 | } 43 | } 44 | 45 | // ValidatedStatusCode returns the HTTP response status code. If the code is not 4xx 46 | // or 5xx, an InternalServerError will be returned instead. 47 | func (f *FailureResponse) ValidatedStatusCode(logger *slog.Logger) int { 48 | if f.statusCode < 400 || 600 <= f.statusCode { 49 | if logger != nil { 50 | logger.Error("validating-status-code", slog.String("error", "Invalid failure http response code: 600, expected 4xx or 5xx, returning internal server error: 500.")) 51 | } 52 | return http.StatusInternalServerError 53 | } 54 | return f.statusCode 55 | } 56 | 57 | // LoggerAction returns the loggerAction, used as the action when logging 58 | func (f *FailureResponse) LoggerAction() string { 59 | return f.loggerAction 60 | } 61 | 62 | // AppendErrorMessage returns an error with the message updated. All other properties are preserved. 63 | func (f *FailureResponse) AppendErrorMessage(msg string) *FailureResponse { 64 | return &FailureResponse{ 65 | error: fmt.Errorf("%s %s", f.Error(), msg), 66 | statusCode: f.statusCode, 67 | loggerAction: f.loggerAction, 68 | emptyResponse: f.emptyResponse, 69 | errorKey: f.errorKey, 70 | } 71 | } 72 | 73 | // FailureResponseBuilder provides a fluent set of methods to build a *FailureResponse. 74 | type FailureResponseBuilder struct { 75 | error 76 | statusCode int 77 | loggerAction string 78 | emptyResponse bool 79 | errorKey string 80 | } 81 | 82 | // NewFailureResponseBuilder returns a pointer to a newly instantiated FailureResponseBuilder 83 | // Accepts required arguments to create a FailureResponse. 84 | func NewFailureResponseBuilder(err error, statusCode int, loggerAction string) *FailureResponseBuilder { 85 | return &FailureResponseBuilder{ 86 | error: err, 87 | statusCode: statusCode, 88 | loggerAction: loggerAction, 89 | emptyResponse: false, 90 | } 91 | } 92 | 93 | // WithErrorKey adds a custom ErrorKey which will be used in FailureResponse to add an `Error` 94 | // field to the JSON HTTP response body 95 | func (f *FailureResponseBuilder) WithErrorKey(errorKey string) *FailureResponseBuilder { 96 | f.errorKey = errorKey 97 | return f 98 | } 99 | 100 | // WithEmptyResponse will cause the built FailureResponse to return an empty JSON object as the 101 | // HTTP response body 102 | func (f *FailureResponseBuilder) WithEmptyResponse() *FailureResponseBuilder { 103 | f.emptyResponse = true 104 | return f 105 | } 106 | 107 | // Build returns the generated FailureResponse built using previously configured variables. 108 | func (f *FailureResponseBuilder) Build() *FailureResponse { 109 | return &FailureResponse{ 110 | error: f.error, 111 | statusCode: f.statusCode, 112 | loggerAction: f.loggerAction, 113 | emptyResponse: f.emptyResponse, 114 | errorKey: f.errorKey, 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /domain/apiresponses/failure_responses_test.go: -------------------------------------------------------------------------------- 1 | package apiresponses_test 2 | 3 | import ( 4 | "errors" 5 | "log/slog" 6 | "net/http" 7 | 8 | "code.cloudfoundry.org/brokerapi/v13/domain/apiresponses" 9 | . "github.com/onsi/ginkgo/v2" 10 | . "github.com/onsi/gomega" 11 | "github.com/onsi/gomega/gbytes" 12 | ) 13 | 14 | var _ = Describe("FailureResponse", func() { 15 | Describe("ErrorResponse", func() { 16 | It("returns a ErrorResponse containing the error message", func() { 17 | failureResponse := asFailureResponse(apiresponses.NewFailureResponse(errors.New("my error message"), http.StatusForbidden, "log-key")) 18 | Expect(failureResponse.ErrorResponse()).To(Equal(apiresponses.ErrorResponse{ 19 | Description: "my error message", 20 | })) 21 | }) 22 | 23 | Context("when the error key is provided", func() { 24 | It("returns a ErrorResponse containing the error message and the error key", func() { 25 | failureResponse := apiresponses.NewFailureResponseBuilder(errors.New("my error message"), http.StatusForbidden, "log-key").WithErrorKey("error key").Build() 26 | Expect(failureResponse.ErrorResponse()).To(Equal(apiresponses.ErrorResponse{ 27 | Description: "my error message", 28 | Error: "error key", 29 | })) 30 | }) 31 | }) 32 | 33 | Context("when created with empty response", func() { 34 | It("returns an EmptyResponse", func() { 35 | failureResponse := apiresponses.NewFailureResponseBuilder(errors.New("my error message"), http.StatusForbidden, "log-key").WithEmptyResponse().Build() 36 | Expect(failureResponse.ErrorResponse()).To(Equal(apiresponses.EmptyResponse{})) 37 | }) 38 | }) 39 | }) 40 | 41 | Describe("AppendErrorMessage", func() { 42 | It("returns the error with the additional error message included, with a non-empty body", func() { 43 | failureResponse := apiresponses.NewFailureResponseBuilder(errors.New("my error message"), http.StatusForbidden, "log-key").WithErrorKey("some-key").Build() 44 | Expect(failureResponse.Error()).To(Equal("my error message")) 45 | 46 | newError := failureResponse.AppendErrorMessage("and some more details") 47 | 48 | Expect(newError.Error()).To(Equal("my error message and some more details")) 49 | Expect(newError.ValidatedStatusCode(nil)).To(Equal(http.StatusForbidden)) 50 | Expect(newError.LoggerAction()).To(Equal(failureResponse.LoggerAction())) 51 | 52 | errorResponse, typeCast := newError.ErrorResponse().(apiresponses.ErrorResponse) 53 | Expect(typeCast).To(BeTrue()) 54 | Expect(errorResponse.Error).To(Equal("some-key")) 55 | Expect(errorResponse.Description).To(Equal("my error message and some more details")) 56 | }) 57 | 58 | It("returns the error with the additional error message included, with an empty body", func() { 59 | failureResponse := apiresponses.NewFailureResponseBuilder(errors.New("my error message"), http.StatusForbidden, "log-key").WithEmptyResponse().Build() 60 | Expect(failureResponse.Error()).To(Equal("my error message")) 61 | 62 | newError := failureResponse.AppendErrorMessage("and some more details") 63 | 64 | Expect(newError.Error()).To(Equal("my error message and some more details")) 65 | Expect(newError.ValidatedStatusCode(nil)).To(Equal(http.StatusForbidden)) 66 | Expect(newError.LoggerAction()).To(Equal(failureResponse.LoggerAction())) 67 | Expect(newError.ErrorResponse()).To(Equal(failureResponse.ErrorResponse())) 68 | }) 69 | }) 70 | 71 | Describe("ValidatedStatusCode", func() { 72 | It("returns the status code that was passed in", func() { 73 | failureResponse := asFailureResponse(apiresponses.NewFailureResponse(errors.New("my error message"), http.StatusForbidden, "log-key")) 74 | Expect(failureResponse.ValidatedStatusCode(nil)).To(Equal(http.StatusForbidden)) 75 | }) 76 | 77 | It("when error key is provided it returns the status code that was passed in", func() { 78 | failureResponse := apiresponses.NewFailureResponseBuilder(errors.New("my error message"), http.StatusForbidden, "log-key").WithErrorKey("error key").Build() 79 | Expect(failureResponse.ValidatedStatusCode(nil)).To(Equal(http.StatusForbidden)) 80 | }) 81 | 82 | Context("when the status code is invalid", func() { 83 | It("returns 500", func() { 84 | failureResponse := asFailureResponse(apiresponses.NewFailureResponse(errors.New("my error message"), 600, "log-key")) 85 | Expect(failureResponse.ValidatedStatusCode(nil)).To(Equal(http.StatusInternalServerError)) 86 | }) 87 | 88 | It("logs that the status has been changed", func() { 89 | log := gbytes.NewBuffer() 90 | logger := slog.New(slog.NewJSONHandler(log, nil)) 91 | failureResponse := asFailureResponse(apiresponses.NewFailureResponse(errors.New("my error message"), 600, "log-key")) 92 | failureResponse.ValidatedStatusCode(logger) 93 | Expect(log).To(gbytes.Say("Invalid failure http response code: 600, expected 4xx or 5xx, returning internal server error: 500.")) 94 | }) 95 | }) 96 | }) 97 | 98 | Describe("LoggerAction", func() { 99 | It("returns the logger action that was passed in", func() { 100 | failureResponse := apiresponses.NewFailureResponseBuilder(errors.New("my error message"), http.StatusForbidden, "log-key").WithErrorKey("error key").Build() 101 | Expect(failureResponse.LoggerAction()).To(Equal("log-key")) 102 | }) 103 | 104 | It("when error key is provided it returns the logger action that was passed in", func() { 105 | failureResponse := asFailureResponse(apiresponses.NewFailureResponse(errors.New("my error message"), http.StatusForbidden, "log-key")) 106 | Expect(failureResponse.LoggerAction()).To(Equal("log-key")) 107 | }) 108 | }) 109 | }) 110 | 111 | func asFailureResponse(err error) *apiresponses.FailureResponse { 112 | GinkgoHelper() 113 | Expect(err).To(BeAssignableToTypeOf(&apiresponses.FailureResponse{})) 114 | return err.(*apiresponses.FailureResponse) 115 | } 116 | -------------------------------------------------------------------------------- /domain/apiresponses/responses.go: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2015-Present Pivotal Software, Inc. All rights reserved. 2 | 3 | // This program and the accompanying materials are made available under 4 | // the terms of the under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | 16 | package apiresponses 17 | 18 | import "code.cloudfoundry.org/brokerapi/v13/domain" 19 | 20 | type EmptyResponse struct{} 21 | 22 | type ErrorResponse struct { 23 | Error string `json:"error,omitempty"` 24 | Description string `json:"description"` 25 | } 26 | 27 | type CatalogResponse struct { 28 | Services []domain.Service `json:"services"` 29 | } 30 | 31 | type ProvisioningResponse struct { 32 | DashboardURL string `json:"dashboard_url,omitempty"` 33 | OperationData string `json:"operation,omitempty"` 34 | Metadata any `json:"metadata,omitempty"` 35 | } 36 | 37 | type GetInstanceResponse struct { 38 | ServiceID string `json:"service_id"` 39 | PlanID string `json:"plan_id"` 40 | DashboardURL string `json:"dashboard_url,omitempty"` 41 | Parameters any `json:"parameters,omitempty"` 42 | Metadata any `json:"metadata,omitempty"` 43 | } 44 | 45 | type UpdateResponse struct { 46 | DashboardURL string `json:"dashboard_url,omitempty"` 47 | OperationData string `json:"operation,omitempty"` 48 | Metadata any `json:"metadata,omitempty"` 49 | } 50 | 51 | type DeprovisionResponse struct { 52 | OperationData string `json:"operation,omitempty"` 53 | } 54 | 55 | type LastOperationResponse struct { 56 | State domain.LastOperationState `json:"state"` 57 | Description string `json:"description,omitempty"` 58 | } 59 | 60 | type AsyncBindResponse struct { 61 | OperationData string `json:"operation,omitempty"` 62 | } 63 | 64 | type BindingResponse struct { 65 | Credentials any `json:"credentials,omitempty"` 66 | SyslogDrainURL string `json:"syslog_drain_url,omitempty"` 67 | RouteServiceURL string `json:"route_service_url,omitempty"` 68 | VolumeMounts []domain.VolumeMount `json:"volume_mounts,omitempty"` 69 | BackupAgentURL string `json:"backup_agent_url,omitempty"` 70 | Endpoints []domain.Endpoint `json:"endpoints,omitempty"` 71 | Metadata any `json:"metadata,omitempty"` 72 | } 73 | 74 | type GetBindingResponse struct { 75 | BindingResponse 76 | Parameters any `json:"parameters,omitempty"` 77 | } 78 | 79 | type UnbindResponse struct { 80 | OperationData string `json:"operation,omitempty"` 81 | } 82 | 83 | type ExperimentalVolumeMountBindingResponse struct { 84 | Credentials any `json:"credentials,omitempty"` 85 | SyslogDrainURL string `json:"syslog_drain_url,omitempty"` 86 | RouteServiceURL string `json:"route_service_url,omitempty"` 87 | VolumeMounts []domain.ExperimentalVolumeMount `json:"volume_mounts,omitempty"` 88 | BackupAgentURL string `json:"backup_agent_url,omitempty"` 89 | } 90 | -------------------------------------------------------------------------------- /domain/apiresponses/responses_test.go: -------------------------------------------------------------------------------- 1 | package apiresponses_test 2 | 3 | import ( 4 | "encoding/json" 5 | 6 | "code.cloudfoundry.org/brokerapi/v13/domain" 7 | "code.cloudfoundry.org/brokerapi/v13/domain/apiresponses" 8 | . "github.com/onsi/ginkgo/v2" 9 | . "github.com/onsi/gomega" 10 | ) 11 | 12 | var _ = Describe("Catalog Response", func() { 13 | Describe("JSON encoding", func() { 14 | It("has a list of services", func() { 15 | catalogResponse := apiresponses.CatalogResponse{ 16 | Services: []domain.Service{}, 17 | } 18 | jsonString := `{"services":[]}` 19 | 20 | Expect(json.Marshal(catalogResponse)).To(MatchJSON(jsonString)) 21 | }) 22 | }) 23 | }) 24 | 25 | var _ = Describe("Provisioning Response", func() { 26 | Describe("JSON encoding", func() { 27 | Context("when the dashboard URL is not present", func() { 28 | It("does not return it in the JSON", func() { 29 | provisioningResponse := apiresponses.ProvisioningResponse{} 30 | jsonString := `{}` 31 | 32 | Expect(json.Marshal(provisioningResponse)).To(MatchJSON(jsonString)) 33 | }) 34 | }) 35 | 36 | Context("when the dashboard URL is present", func() { 37 | It("returns it in the JSON", func() { 38 | provisioningResponse := apiresponses.ProvisioningResponse{ 39 | DashboardURL: "http://example.com/broker", 40 | } 41 | jsonString := `{"dashboard_url":"http://example.com/broker"}` 42 | 43 | Expect(json.Marshal(provisioningResponse)).To(MatchJSON(jsonString)) 44 | }) 45 | }) 46 | 47 | Context("when the metadata is present", func() { 48 | It("returns it in the JSON", func() { 49 | provisioningResponse := apiresponses.ProvisioningResponse{ 50 | Metadata: domain.InstanceMetadata{ 51 | Labels: map[string]any{"key1": "value1"}, 52 | Attributes: map[string]any{"key1": "value1"}, 53 | }, 54 | } 55 | jsonString := `{"metadata":{"labels":{"key1":"value1"}, "attributes":{"key1":"value1"}}}` 56 | 57 | Expect(json.Marshal(provisioningResponse)).To(MatchJSON(jsonString)) 58 | }) 59 | }) 60 | }) 61 | }) 62 | 63 | var _ = Describe("Fetching Response", func() { 64 | Describe("JSON encoding", func() { 65 | Context("when the dashboard URL and parameters are present", func() { 66 | It("returns it in the JSON", func() { 67 | fetchingResponse := apiresponses.GetInstanceResponse{ 68 | ServiceID: "sID", 69 | PlanID: "pID", 70 | DashboardURL: "http://example.com/broker", 71 | Parameters: map[string]string{"param1": "value1"}, 72 | } 73 | jsonString := `{"service_id":"sID", "plan_id":"pID", "dashboard_url":"http://example.com/broker", "parameters": {"param1":"value1"}}` 74 | 75 | Expect(json.Marshal(fetchingResponse)).To(MatchJSON(jsonString)) 76 | }) 77 | }) 78 | 79 | Context("when the metadata is present", func() { 80 | It("returns it in the JSON", func() { 81 | fetchingResponse := apiresponses.GetInstanceResponse{ 82 | ServiceID: "sID", 83 | PlanID: "pID", 84 | Metadata: domain.InstanceMetadata{ 85 | Labels: map[string]any{"key1": "value1"}, 86 | Attributes: map[string]any{"key1": "value1"}, 87 | }, 88 | } 89 | jsonString := `{"service_id":"sID", "plan_id":"pID", "metadata":{"labels":{"key1":"value1"}, "attributes":{"key1":"value1"}}}` 90 | 91 | Expect(json.Marshal(fetchingResponse)).To(MatchJSON(jsonString)) 92 | }) 93 | }) 94 | }) 95 | }) 96 | 97 | var _ = Describe("Update Response", func() { 98 | Describe("JSON encoding", func() { 99 | Context("when the dashboard URL is not present", func() { 100 | It("does not return it in the JSON", func() { 101 | updateResponse := apiresponses.UpdateResponse{} 102 | jsonString := `{}` 103 | 104 | Expect(json.Marshal(updateResponse)).To(MatchJSON(jsonString)) 105 | }) 106 | }) 107 | 108 | Context("when the dashboard URL is present", func() { 109 | It("returns it in the JSON", func() { 110 | updateResponse := apiresponses.UpdateResponse{ 111 | DashboardURL: "http://example.com/broker_updated", 112 | } 113 | jsonString := `{"dashboard_url":"http://example.com/broker_updated"}` 114 | 115 | Expect(json.Marshal(updateResponse)).To(MatchJSON(jsonString)) 116 | }) 117 | }) 118 | 119 | Context("when the metadata is present", func() { 120 | It("returns it in the JSON", func() { 121 | updateResponse := apiresponses.UpdateResponse{ 122 | Metadata: domain.InstanceMetadata{ 123 | Labels: map[string]any{"key1": "value1"}, 124 | Attributes: map[string]any{"key1": "value1"}, 125 | }, 126 | } 127 | jsonString := `{"metadata":{"labels":{"key1":"value1"}, "attributes":{"key1":"value1"}}}` 128 | 129 | Expect(json.Marshal(updateResponse)).To(MatchJSON(jsonString)) 130 | }) 131 | }) 132 | }) 133 | }) 134 | 135 | var _ = Describe("Error Response", func() { 136 | Describe("JSON encoding", func() { 137 | It("has a description field", func() { 138 | errorResponse := apiresponses.ErrorResponse{ 139 | Description: "a bad thing happened", 140 | } 141 | jsonString := `{"description":"a bad thing happened"}` 142 | 143 | Expect(json.Marshal(errorResponse)).To(MatchJSON(jsonString)) 144 | }) 145 | }) 146 | }) 147 | -------------------------------------------------------------------------------- /domain/domain_suite_test.go: -------------------------------------------------------------------------------- 1 | package domain_test 2 | 3 | import ( 4 | "testing" 5 | 6 | . "github.com/onsi/ginkgo/v2" 7 | . "github.com/onsi/gomega" 8 | ) 9 | 10 | func TestDomain(t *testing.T) { 11 | RegisterFailHandler(Fail) 12 | RunSpecs(t, "Domain Suite") 13 | } 14 | -------------------------------------------------------------------------------- /domain/experimental_volume_mount.go: -------------------------------------------------------------------------------- 1 | package domain 2 | 3 | type ExperimentalVolumeMount struct { 4 | ContainerPath string `json:"container_path"` 5 | Mode string `json:"mode"` 6 | Private ExperimentalVolumeMountPrivate `json:"private"` 7 | } 8 | 9 | type ExperimentalVolumeMountPrivate struct { 10 | Driver string `json:"driver"` 11 | GroupID string `json:"group_id"` 12 | Config string `json:"config"` 13 | } 14 | -------------------------------------------------------------------------------- /domain/maintenance_info.go: -------------------------------------------------------------------------------- 1 | package domain 2 | 3 | import "reflect" 4 | 5 | type MaintenanceInfo struct { 6 | Public map[string]string `json:"public,omitempty"` 7 | Private string `json:"private,omitempty"` 8 | Version string `json:"version,omitempty"` 9 | Description string `json:"description,omitempty"` 10 | } 11 | 12 | func (m *MaintenanceInfo) Equals(input MaintenanceInfo) bool { 13 | return m.Version == input.Version && 14 | m.Private == input.Private && 15 | reflect.DeepEqual(m.Public, input.Public) 16 | } 17 | -------------------------------------------------------------------------------- /domain/maintenance_info_test.go: -------------------------------------------------------------------------------- 1 | package domain_test 2 | 3 | import ( 4 | "code.cloudfoundry.org/brokerapi/v13/domain" 5 | . "github.com/onsi/ginkgo/v2" 6 | . "github.com/onsi/gomega" 7 | ) 8 | 9 | var _ = Describe("MaintenanceInfo", func() { 10 | Describe("Equals", func() { 11 | DescribeTable( 12 | "returns false", 13 | func(m1, m2 domain.MaintenanceInfo) { 14 | Expect(m1.Equals(m2)).To(BeFalse()) 15 | }, 16 | Entry( 17 | "one property is missing", 18 | domain.MaintenanceInfo{ 19 | Public: map[string]string{"foo": "bar"}, 20 | Private: "test", 21 | Version: "1.2.3", 22 | }, 23 | domain.MaintenanceInfo{ 24 | Public: map[string]string{"foo": "bar"}, 25 | Private: "test", 26 | }), 27 | Entry( 28 | "one extra property is added", 29 | domain.MaintenanceInfo{ 30 | Public: map[string]string{"foo": "bar"}, 31 | Private: "test", 32 | Description: "test", 33 | }, 34 | domain.MaintenanceInfo{ 35 | Public: map[string]string{"foo": "bar"}, 36 | Private: "test", 37 | Version: "1.2.3", 38 | Description: "test", 39 | }), 40 | Entry("public field is different", 41 | domain.MaintenanceInfo{Public: map[string]string{"foo": "bar"}}, 42 | domain.MaintenanceInfo{Public: map[string]string{"foo": "foo"}}, 43 | ), 44 | Entry("private field is different", 45 | domain.MaintenanceInfo{Private: "foo"}, 46 | domain.MaintenanceInfo{Private: "bar"}, 47 | ), 48 | Entry("version field is different", 49 | domain.MaintenanceInfo{Version: "1.2.0"}, 50 | domain.MaintenanceInfo{Version: "2.2.2"}, 51 | ), 52 | Entry( 53 | "all properties are missing in one of the objects", 54 | domain.MaintenanceInfo{ 55 | Public: map[string]string{"foo": "bar"}, 56 | Private: "test", 57 | Version: "1.2.3", 58 | Description: "test", 59 | }, 60 | domain.MaintenanceInfo{}), 61 | ) 62 | 63 | DescribeTable( 64 | "returns true", 65 | func(m1, m2 domain.MaintenanceInfo) { 66 | Expect(m1.Equals(m2)).To(BeTrue()) 67 | }, 68 | Entry( 69 | "all properties are the same", 70 | domain.MaintenanceInfo{ 71 | Public: map[string]string{"foo": "bar"}, 72 | Private: "test", 73 | Version: "1.2.3", 74 | Description: "test", 75 | }, 76 | domain.MaintenanceInfo{ 77 | Public: map[string]string{"foo": "bar"}, 78 | Private: "test", 79 | Version: "1.2.3", 80 | Description: "test", 81 | }), 82 | Entry( 83 | "all properties are empty", 84 | domain.MaintenanceInfo{}, 85 | domain.MaintenanceInfo{}), 86 | Entry( 87 | "both struct's are nil", 88 | nil, 89 | nil), 90 | Entry("description field is different", 91 | domain.MaintenanceInfo{Description: "amazing"}, 92 | domain.MaintenanceInfo{Description: "terrible"}, 93 | ), 94 | ) 95 | }) 96 | }) 97 | -------------------------------------------------------------------------------- /domain/service_broker.go: -------------------------------------------------------------------------------- 1 | package domain 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | ) 7 | 8 | //go:generate go run github.com/maxbrunsfeld/counterfeiter/v6 -generate 9 | //counterfeiter:generate -o ../fakes/auto_fake_service_broker.go -fake-name AutoFakeServiceBroker . ServiceBroker 10 | 11 | // Each method of the ServiceBroker interface maps to an individual endpoint of the Open Service Broker API. 12 | // The specification is available here: https://github.com/openservicebrokerapi/servicebroker/blob/v2.14/spec.md 13 | // The OpenAPI documentation is available here: http://petstore.swagger.io/?url=https://raw.githubusercontent.com/openservicebrokerapi/servicebroker/v2.14/openapi.yaml 14 | type ServiceBroker interface { 15 | 16 | // Services gets the catalog of services offered by the service broker 17 | // GET /v2/catalog 18 | Services(ctx context.Context) ([]Service, error) 19 | 20 | // Provision creates a new service instance 21 | // PUT /v2/service_instances/{instance_id} 22 | Provision(ctx context.Context, instanceID string, details ProvisionDetails, asyncAllowed bool) (ProvisionedServiceSpec, error) 23 | 24 | // Deprovision deletes an existing service instance 25 | // DELETE /v2/service_instances/{instance_id} 26 | Deprovision(ctx context.Context, instanceID string, details DeprovisionDetails, asyncAllowed bool) (DeprovisionServiceSpec, error) 27 | 28 | // GetInstance fetches information about a service instance 29 | // GET /v2/service_instances/{instance_id} 30 | GetInstance(ctx context.Context, instanceID string, details FetchInstanceDetails) (GetInstanceDetailsSpec, error) 31 | 32 | // Update modifies an existing service instance 33 | // PATCH /v2/service_instances/{instance_id} 34 | Update(ctx context.Context, instanceID string, details UpdateDetails, asyncAllowed bool) (UpdateServiceSpec, error) 35 | 36 | // LastOperation fetches last operation state for a service instance 37 | // GET /v2/service_instances/{instance_id}/last_operation 38 | LastOperation(ctx context.Context, instanceID string, details PollDetails) (LastOperation, error) 39 | 40 | // Bind creates a new service binding 41 | // PUT /v2/service_instances/{instance_id}/service_bindings/{binding_id} 42 | Bind(ctx context.Context, instanceID, bindingID string, details BindDetails, asyncAllowed bool) (Binding, error) 43 | 44 | // Unbind deletes an existing service binding 45 | // DELETE /v2/service_instances/{instance_id}/service_bindings/{binding_id} 46 | Unbind(ctx context.Context, instanceID, bindingID string, details UnbindDetails, asyncAllowed bool) (UnbindSpec, error) 47 | 48 | // GetBinding fetches an existing service binding 49 | // GET /v2/service_instances/{instance_id}/service_bindings/{binding_id} 50 | GetBinding(ctx context.Context, instanceID, bindingID string, details FetchBindingDetails) (GetBindingSpec, error) 51 | 52 | // LastBindingOperation fetches last operation state for a service binding 53 | // GET /v2/service_instances/{instance_id}/service_bindings/{binding_id}/last_operation 54 | LastBindingOperation(ctx context.Context, instanceID, bindingID string, details PollDetails) (LastOperation, error) 55 | } 56 | 57 | type LastOperation struct { 58 | State LastOperationState `json:"state"` 59 | Description string `json:"description"` 60 | } 61 | 62 | type LastOperationState string 63 | 64 | const ( 65 | InProgress LastOperationState = "in progress" 66 | Succeeded LastOperationState = "succeeded" 67 | Failed LastOperationState = "failed" 68 | ) 69 | 70 | type VolumeMount struct { 71 | Driver string `json:"driver"` 72 | ContainerDir string `json:"container_dir"` 73 | Mode string `json:"mode"` 74 | DeviceType string `json:"device_type"` 75 | Device SharedDevice `json:"device"` 76 | } 77 | 78 | type SharedDevice struct { 79 | VolumeId string `json:"volume_id"` 80 | MountConfig map[string]any `json:"mount_config"` 81 | } 82 | 83 | type ProvisionDetails struct { 84 | ServiceID string `json:"service_id"` 85 | PlanID string `json:"plan_id"` 86 | OrganizationGUID string `json:"organization_guid"` 87 | SpaceGUID string `json:"space_guid"` 88 | RawContext json.RawMessage `json:"context,omitempty"` 89 | RawParameters json.RawMessage `json:"parameters,omitempty"` 90 | MaintenanceInfo *MaintenanceInfo `json:"maintenance_info,omitempty"` 91 | } 92 | 93 | type ProvisionedServiceSpec struct { 94 | IsAsync bool 95 | AlreadyExists bool 96 | DashboardURL string 97 | OperationData string 98 | Metadata InstanceMetadata 99 | } 100 | 101 | type InstanceMetadata struct { 102 | Labels map[string]any `json:"labels,omitempty"` 103 | Attributes map[string]any `json:"attributes,omitempty"` 104 | } 105 | 106 | type DeprovisionDetails struct { 107 | PlanID string `json:"plan_id"` 108 | ServiceID string `json:"service_id"` 109 | Force bool `json:"force"` 110 | } 111 | 112 | type DeprovisionServiceSpec struct { 113 | IsAsync bool 114 | OperationData string 115 | } 116 | 117 | type GetInstanceDetailsSpec struct { 118 | ServiceID string `json:"service_id"` 119 | PlanID string `json:"plan_id"` 120 | DashboardURL string `json:"dashboard_url"` 121 | Parameters any `json:"parameters"` 122 | Metadata InstanceMetadata 123 | } 124 | 125 | type UpdateDetails struct { 126 | ServiceID string `json:"service_id"` 127 | PlanID string `json:"plan_id"` 128 | RawParameters json.RawMessage `json:"parameters,omitempty"` 129 | PreviousValues PreviousValues `json:"previous_values"` 130 | RawContext json.RawMessage `json:"context,omitempty"` 131 | MaintenanceInfo *MaintenanceInfo `json:"maintenance_info,omitempty"` 132 | } 133 | 134 | type PreviousValues struct { 135 | PlanID string `json:"plan_id"` 136 | ServiceID string `json:"service_id"` 137 | OrgID string `json:"organization_id"` 138 | SpaceID string `json:"space_id"` 139 | MaintenanceInfo *MaintenanceInfo `json:"maintenance_info,omitempty"` 140 | } 141 | 142 | type UpdateServiceSpec struct { 143 | IsAsync bool 144 | DashboardURL string 145 | OperationData string 146 | Metadata InstanceMetadata 147 | } 148 | 149 | type FetchInstanceDetails struct { 150 | ServiceID string `json:"service_id"` 151 | PlanID string `json:"plan_id"` 152 | } 153 | 154 | type FetchBindingDetails struct { 155 | ServiceID string `json:"service_id"` 156 | PlanID string `json:"plan_id"` 157 | } 158 | 159 | type PollDetails struct { 160 | ServiceID string `json:"service_id"` 161 | PlanID string `json:"plan_id"` 162 | OperationData string `json:"operation"` 163 | } 164 | 165 | type BindDetails struct { 166 | AppGUID string `json:"app_guid"` 167 | PlanID string `json:"plan_id"` 168 | ServiceID string `json:"service_id"` 169 | BindResource *BindResource `json:"bind_resource,omitempty"` 170 | RawContext json.RawMessage `json:"context,omitempty"` 171 | RawParameters json.RawMessage `json:"parameters,omitempty"` 172 | } 173 | 174 | type BindResource struct { 175 | AppGuid string `json:"app_guid,omitempty"` 176 | SpaceGuid string `json:"space_guid,omitempty"` 177 | Route string `json:"route,omitempty"` 178 | CredentialClientID string `json:"credential_client_id,omitempty"` 179 | BackupAgent bool `json:"backup_agent,omitempty"` 180 | } 181 | 182 | type UnbindDetails struct { 183 | PlanID string `json:"plan_id"` 184 | ServiceID string `json:"service_id"` 185 | } 186 | 187 | type UnbindSpec struct { 188 | IsAsync bool 189 | OperationData string 190 | } 191 | 192 | type Binding struct { 193 | IsAsync bool `json:"is_async"` 194 | AlreadyExists bool `json:"already_exists"` 195 | OperationData string `json:"operation_data"` 196 | Credentials any `json:"credentials"` 197 | SyslogDrainURL string `json:"syslog_drain_url"` 198 | RouteServiceURL string `json:"route_service_url"` 199 | BackupAgentURL string `json:"backup_agent_url,omitempty"` 200 | VolumeMounts []VolumeMount `json:"volume_mounts"` 201 | Endpoints []Endpoint `json:"endpoints,omitempty"` 202 | Metadata BindingMetadata `json:"metadata,omitempty"` 203 | } 204 | 205 | type BindingMetadata struct { 206 | ExpiresAt string `json:"expires_at,omitempty"` 207 | RenewBefore string `json:"renew_before,omitempty"` 208 | } 209 | 210 | type GetBindingSpec struct { 211 | Credentials any 212 | SyslogDrainURL string 213 | RouteServiceURL string 214 | VolumeMounts []VolumeMount 215 | Parameters any 216 | Endpoints []Endpoint 217 | Metadata BindingMetadata 218 | } 219 | 220 | type Endpoint struct { 221 | Host string `json:"host"` 222 | Ports []string `json:"ports"` 223 | Protocol string `json:"protocol,omitempty"` 224 | } 225 | 226 | func (d ProvisionDetails) GetRawContext() json.RawMessage { 227 | return d.RawContext 228 | } 229 | 230 | func (d ProvisionDetails) GetRawParameters() json.RawMessage { 231 | return d.RawParameters 232 | } 233 | 234 | func (d BindDetails) GetRawContext() json.RawMessage { 235 | return d.RawContext 236 | } 237 | 238 | func (d BindDetails) GetRawParameters() json.RawMessage { 239 | return d.RawParameters 240 | } 241 | 242 | func (m InstanceMetadata) IsEmpty() bool { 243 | return len(m.Attributes) == 0 && len(m.Labels) == 0 244 | } 245 | 246 | func (m BindingMetadata) IsEmpty() bool { 247 | return len(m.ExpiresAt) == 0 && len(m.RenewBefore) == 0 248 | } 249 | 250 | func (d UpdateDetails) GetRawContext() json.RawMessage { 251 | return d.RawContext 252 | } 253 | 254 | func (d UpdateDetails) GetRawParameters() json.RawMessage { 255 | return d.RawParameters 256 | } 257 | 258 | type DetailsWithRawParameters interface { 259 | GetRawParameters() json.RawMessage 260 | } 261 | 262 | type DetailsWithRawContext interface { 263 | GetRawContext() json.RawMessage 264 | } 265 | -------------------------------------------------------------------------------- /domain/service_catalog.go: -------------------------------------------------------------------------------- 1 | package domain 2 | 3 | const ( 4 | PermissionRouteForwarding = RequiredPermission("route_forwarding") 5 | PermissionSyslogDrain = RequiredPermission("syslog_drain") 6 | PermissionVolumeMount = RequiredPermission("volume_mount") 7 | 8 | additionalMetadataName = "AdditionalMetadata" 9 | ) 10 | 11 | type Service struct { 12 | ID string `json:"id"` 13 | Name string `json:"name"` 14 | Description string `json:"description"` 15 | Bindable bool `json:"bindable"` 16 | InstancesRetrievable bool `json:"instances_retrievable,omitempty"` 17 | BindingsRetrievable bool `json:"bindings_retrievable,omitempty"` 18 | Tags []string `json:"tags,omitempty"` 19 | PlanUpdatable bool `json:"plan_updateable"` 20 | Plans []ServicePlan `json:"plans"` 21 | Requires []RequiredPermission `json:"requires,omitempty"` 22 | Metadata *ServiceMetadata `json:"metadata,omitempty"` 23 | DashboardClient *ServiceDashboardClient `json:"dashboard_client,omitempty"` 24 | AllowContextUpdates bool `json:"allow_context_updates,omitempty"` 25 | } 26 | 27 | type ServicePlan struct { 28 | ID string `json:"id"` 29 | Name string `json:"name"` 30 | Description string `json:"description"` 31 | Free *bool `json:"free,omitempty"` 32 | Bindable *bool `json:"bindable,omitempty"` 33 | Metadata *ServicePlanMetadata `json:"metadata,omitempty"` 34 | Schemas *ServiceSchemas `json:"schemas,omitempty"` 35 | PlanUpdatable *bool `json:"plan_updateable,omitempty"` 36 | MaximumPollingDuration *int `json:"maximum_polling_duration,omitempty"` 37 | MaintenanceInfo *MaintenanceInfo `json:"maintenance_info,omitempty"` 38 | } 39 | 40 | type ServiceSchemas struct { 41 | Instance ServiceInstanceSchema `json:"service_instance,omitempty"` 42 | Binding ServiceBindingSchema `json:"service_binding,omitempty"` 43 | } 44 | 45 | type ServiceInstanceSchema struct { 46 | Create Schema `json:"create,omitempty"` 47 | Update Schema `json:"update,omitempty"` 48 | } 49 | 50 | type ServiceBindingSchema struct { 51 | Create Schema `json:"create,omitempty"` 52 | } 53 | 54 | type Schema struct { 55 | Parameters map[string]any `json:"parameters"` 56 | } 57 | 58 | type RequiredPermission string 59 | 60 | func FreeValue(v bool) *bool { 61 | return &v 62 | } 63 | 64 | func BindableValue(v bool) *bool { 65 | return &v 66 | } 67 | 68 | func PlanUpdatableValue(v bool) *bool { 69 | return &v 70 | } 71 | 72 | type ServiceDashboardClient struct { 73 | ID string `json:"id"` 74 | Secret string `json:"secret"` 75 | RedirectURI string `json:"redirect_uri"` 76 | } 77 | -------------------------------------------------------------------------------- /domain/service_catalog_test.go: -------------------------------------------------------------------------------- 1 | package domain_test 2 | 3 | import ( 4 | "encoding/json" 5 | 6 | "code.cloudfoundry.org/brokerapi/v13/domain" 7 | . "github.com/onsi/ginkgo/v2" 8 | . "github.com/onsi/gomega" 9 | ) 10 | 11 | var maximumPollingDuration = 3600 12 | 13 | var _ = Describe("ServiceCatalog", func() { 14 | Describe("Service", func() { 15 | Describe("JSON encoding", func() { 16 | It("uses the correct keys", func() { 17 | service := domain.Service{ 18 | ID: "ID-1", 19 | Name: "Cassandra", 20 | Description: "A Cassandra Plan", 21 | Bindable: true, 22 | Plans: []domain.ServicePlan{}, 23 | Metadata: &domain.ServiceMetadata{}, 24 | Tags: []string{"test"}, 25 | PlanUpdatable: true, 26 | DashboardClient: &domain.ServiceDashboardClient{ 27 | ID: "Dashboard ID", 28 | Secret: "dashboardsecret", 29 | RedirectURI: "the.dashboa.rd", 30 | }, 31 | AllowContextUpdates: true, 32 | } 33 | jsonString := `{ 34 | "id":"ID-1", 35 | "name":"Cassandra", 36 | "description":"A Cassandra Plan", 37 | "bindable":true, 38 | "plan_updateable":true, 39 | "tags":["test"], 40 | "plans":[], 41 | "allow_context_updates":true, 42 | "dashboard_client":{ 43 | "id":"Dashboard ID", 44 | "secret":"dashboardsecret", 45 | "redirect_uri":"the.dashboa.rd" 46 | }, 47 | "metadata":{ 48 | 49 | } 50 | }` 51 | Expect(json.Marshal(service)).To(MatchJSON(jsonString)) 52 | }) 53 | }) 54 | 55 | It("encodes the optional 'requires' fields", func() { 56 | service := domain.Service{ 57 | ID: "ID-1", 58 | Name: "Cassandra", 59 | Description: "A Cassandra Plan", 60 | Bindable: true, 61 | Plans: []domain.ServicePlan{}, 62 | Metadata: &domain.ServiceMetadata{}, 63 | Tags: []string{"test"}, 64 | PlanUpdatable: true, 65 | Requires: []domain.RequiredPermission{ 66 | domain.PermissionRouteForwarding, 67 | domain.PermissionSyslogDrain, 68 | domain.PermissionVolumeMount, 69 | }, 70 | DashboardClient: &domain.ServiceDashboardClient{ 71 | ID: "Dashboard ID", 72 | Secret: "dashboardsecret", 73 | RedirectURI: "the.dashboa.rd", 74 | }, 75 | } 76 | jsonString := `{ 77 | "id":"ID-1", 78 | "name":"Cassandra", 79 | "description":"A Cassandra Plan", 80 | "bindable":true, 81 | "plan_updateable":true, 82 | "tags":["test"], 83 | "plans":[], 84 | "requires": ["route_forwarding", "syslog_drain", "volume_mount"], 85 | "dashboard_client":{ 86 | "id":"Dashboard ID", 87 | "secret":"dashboardsecret", 88 | "redirect_uri":"the.dashboa.rd" 89 | }, 90 | "metadata":{ 91 | 92 | } 93 | }` 94 | Expect(json.Marshal(service)).To(MatchJSON(jsonString)) 95 | }) 96 | }) 97 | 98 | Describe("ServicePlan", func() { 99 | Describe("JSON encoding", func() { 100 | It("uses the correct keys", func() { 101 | plan := domain.ServicePlan{ 102 | ID: "ID-1", 103 | Name: "Cassandra", 104 | Description: "A Cassandra Plan", 105 | Bindable: domain.BindableValue(true), 106 | Free: domain.FreeValue(true), 107 | PlanUpdatable: domain.PlanUpdatableValue(true), 108 | Metadata: &domain.ServicePlanMetadata{ 109 | Bullets: []string{"hello", "its me"}, 110 | DisplayName: "name", 111 | }, 112 | MaximumPollingDuration: &maximumPollingDuration, 113 | MaintenanceInfo: &domain.MaintenanceInfo{ 114 | Public: map[string]string{ 115 | "name": "foo", 116 | }, 117 | Private: "someprivatehashedvalue", 118 | Version: "8.1.0", 119 | Description: "test", 120 | }, 121 | } 122 | jsonString := `{ 123 | "id":"ID-1", 124 | "name":"Cassandra", 125 | "description":"A Cassandra Plan", 126 | "free": true, 127 | "bindable": true, 128 | "metadata":{ 129 | "bullets":["hello", "its me"], 130 | "displayName":"name" 131 | }, 132 | "plan_updateable": true, 133 | "maximum_polling_duration": 3600, 134 | "maintenance_info": { 135 | "public": { 136 | "name": "foo" 137 | }, 138 | "private": "someprivatehashedvalue", 139 | "version": "8.1.0", 140 | "description": "test" 141 | } 142 | }` 143 | 144 | Expect(json.Marshal(plan)).To(MatchJSON(jsonString)) 145 | }) 146 | }) 147 | }) 148 | }) 149 | -------------------------------------------------------------------------------- /domain/service_metadata.go: -------------------------------------------------------------------------------- 1 | package domain 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "reflect" 7 | ) 8 | 9 | type ServiceMetadata struct { 10 | DisplayName string `json:"displayName,omitempty"` 11 | ImageUrl string `json:"imageUrl,omitempty"` 12 | LongDescription string `json:"longDescription,omitempty"` 13 | ProviderDisplayName string `json:"providerDisplayName,omitempty"` 14 | DocumentationUrl string `json:"documentationUrl,omitempty"` 15 | SupportUrl string `json:"supportUrl,omitempty"` 16 | Shareable *bool `json:"shareable,omitempty"` 17 | AdditionalMetadata map[string]any 18 | } 19 | 20 | func (sm ServiceMetadata) MarshalJSON() ([]byte, error) { 21 | type Alias ServiceMetadata 22 | 23 | b, err := json.Marshal(Alias(sm)) 24 | if err != nil { 25 | return nil, fmt.Errorf("unmarshallable content in AdditionalMetadata: %w", err) 26 | } 27 | 28 | var m map[string]any 29 | if err := json.Unmarshal(b, &m); err != nil { 30 | return nil, err 31 | } 32 | delete(m, additionalMetadataName) 33 | 34 | for k, v := range sm.AdditionalMetadata { 35 | m[k] = v 36 | } 37 | return json.Marshal(m) 38 | } 39 | 40 | func (sm *ServiceMetadata) UnmarshalJSON(data []byte) error { 41 | type Alias ServiceMetadata 42 | 43 | if err := json.Unmarshal(data, (*Alias)(sm)); err != nil { 44 | return err 45 | } 46 | 47 | additionalMetadata := map[string]any{} 48 | if err := json.Unmarshal(data, &additionalMetadata); err != nil { 49 | return err 50 | } 51 | 52 | for _, jsonName := range GetJsonNames(reflect.ValueOf(sm).Elem()) { 53 | if jsonName == additionalMetadataName { 54 | continue 55 | } 56 | delete(additionalMetadata, jsonName) 57 | } 58 | 59 | if len(additionalMetadata) > 0 { 60 | sm.AdditionalMetadata = additionalMetadata 61 | } 62 | return nil 63 | } 64 | -------------------------------------------------------------------------------- /domain/service_metadata_test.go: -------------------------------------------------------------------------------- 1 | package domain_test 2 | 3 | import ( 4 | "encoding/json" 5 | "sync" 6 | 7 | "code.cloudfoundry.org/brokerapi/v13/domain" 8 | . "github.com/onsi/ginkgo/v2" 9 | . "github.com/onsi/gomega" 10 | ) 11 | 12 | var _ = Describe("ServiceMetadata", func() { 13 | Describe("JSON encoding", func() { 14 | It("uses the correct keys", func() { 15 | shareable := true 16 | metadata := domain.ServiceMetadata{ 17 | DisplayName: "Cassandra", 18 | LongDescription: "A long description of Cassandra", 19 | DocumentationUrl: "doc", 20 | SupportUrl: "support", 21 | ImageUrl: "image", 22 | ProviderDisplayName: "display", 23 | Shareable: &shareable, 24 | } 25 | jsonString := `{ 26 | "displayName":"Cassandra", 27 | "longDescription":"A long description of Cassandra", 28 | "documentationUrl":"doc", 29 | "supportUrl":"support", 30 | "imageUrl":"image", 31 | "providerDisplayName":"display", 32 | "shareable":true 33 | }` 34 | 35 | Expect(json.Marshal(metadata)).To(MatchJSON(jsonString)) 36 | }) 37 | 38 | It("encodes the AdditionalMetadata fields in the metadata fields", func() { 39 | metadata := domain.ServiceMetadata{ 40 | DisplayName: "name", 41 | AdditionalMetadata: map[string]any{ 42 | "foo": "bar", 43 | "baz": 1, 44 | }, 45 | } 46 | jsonString := `{ 47 | "displayName":"name", 48 | "foo": "bar", 49 | "baz": 1 50 | }` 51 | 52 | Expect(json.Marshal(metadata)).To(MatchJSON(jsonString)) 53 | 54 | By("not mutating the AdditionalMetadata during custom JSON marshalling") 55 | Expect(len(metadata.AdditionalMetadata)).To(Equal(2)) 56 | }) 57 | 58 | It("it can marshal same structure in parallel requests", func() { 59 | metadata := domain.ServiceMetadata{ 60 | DisplayName: "name", 61 | AdditionalMetadata: map[string]any{ 62 | "foo": "bar", 63 | "baz": 1, 64 | }, 65 | } 66 | jsonString := `{ 67 | "displayName":"name", 68 | "foo": "bar", 69 | "baz": 1 70 | }` 71 | 72 | var wg sync.WaitGroup 73 | wg.Add(2) 74 | 75 | for i := 0; i < 2; i++ { 76 | go func() { 77 | defer wg.Done() 78 | defer GinkgoRecover() 79 | 80 | Expect(json.Marshal(metadata)).To(MatchJSON(jsonString)) 81 | }() 82 | } 83 | wg.Wait() 84 | }) 85 | 86 | It("returns an error when additional metadata is not marshallable", func() { 87 | metadata := domain.ServiceMetadata{ 88 | DisplayName: "name", 89 | AdditionalMetadata: map[string]any{ 90 | "foo": make(chan int), 91 | }, 92 | } 93 | _, err := json.Marshal(metadata) 94 | Expect(err).To(MatchError(ContainSubstring("unmarshallable content in AdditionalMetadata"))) 95 | }) 96 | }) 97 | 98 | Describe("JSON decoding", func() { 99 | It("sets the AdditionalMetadata from unrecognized fields", func() { 100 | metadata := domain.ServiceMetadata{} 101 | jsonString := `{"foo":["test"],"bar":"Some display name"}` 102 | 103 | err := json.Unmarshal([]byte(jsonString), &metadata) 104 | Expect(err).NotTo(HaveOccurred()) 105 | Expect(metadata.AdditionalMetadata["foo"]).To(Equal([]any{"test"})) 106 | Expect(metadata.AdditionalMetadata["bar"]).To(Equal("Some display name")) 107 | }) 108 | 109 | It("does not include convention fields into additional metadata", func() { 110 | metadata := domain.ServiceMetadata{} 111 | jsonString := `{ 112 | "displayName":"Cassandra", 113 | "longDescription":"A long description of Cassandra", 114 | "documentationUrl":"doc", 115 | "supportUrl":"support", 116 | "imageUrl":"image", 117 | "providerDisplayName":"display", 118 | "shareable":true 119 | }` 120 | err := json.Unmarshal([]byte(jsonString), &metadata) 121 | Expect(err).NotTo(HaveOccurred()) 122 | Expect(metadata.AdditionalMetadata).To(BeNil()) 123 | }) 124 | }) 125 | }) 126 | -------------------------------------------------------------------------------- /domain/service_plan_metadata.go: -------------------------------------------------------------------------------- 1 | package domain 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "reflect" 7 | "strings" 8 | ) 9 | 10 | type ServicePlanMetadata struct { 11 | DisplayName string `json:"displayName,omitempty"` 12 | Bullets []string `json:"bullets,omitempty"` 13 | Costs []ServicePlanCost `json:"costs,omitempty"` 14 | AdditionalMetadata map[string]any 15 | } 16 | 17 | type ServicePlanCost struct { 18 | Amount map[string]float64 `json:"amount"` 19 | Unit string `json:"unit"` 20 | } 21 | 22 | func (spm *ServicePlanMetadata) UnmarshalJSON(data []byte) error { 23 | type Alias ServicePlanMetadata 24 | 25 | if err := json.Unmarshal(data, (*Alias)(spm)); err != nil { 26 | return err 27 | } 28 | 29 | additionalMetadata := map[string]any{} 30 | if err := json.Unmarshal(data, &additionalMetadata); err != nil { 31 | return err 32 | } 33 | 34 | s := reflect.ValueOf(spm).Elem() 35 | for _, jsonName := range GetJsonNames(s) { 36 | if jsonName == additionalMetadataName { 37 | continue 38 | } 39 | delete(additionalMetadata, jsonName) 40 | } 41 | 42 | if len(additionalMetadata) > 0 { 43 | spm.AdditionalMetadata = additionalMetadata 44 | } 45 | return nil 46 | } 47 | 48 | func (spm ServicePlanMetadata) MarshalJSON() ([]byte, error) { 49 | type Alias ServicePlanMetadata 50 | 51 | b, err := json.Marshal(Alias(spm)) 52 | if err != nil { 53 | return nil, fmt.Errorf("unmarshallable content in AdditionalMetadata: %w", err) 54 | } 55 | 56 | var m map[string]any 57 | if err := json.Unmarshal(b, &m); err != nil { 58 | return nil, err 59 | } 60 | delete(m, additionalMetadataName) 61 | 62 | for k, v := range spm.AdditionalMetadata { 63 | m[k] = v 64 | } 65 | 66 | return json.Marshal(m) 67 | } 68 | 69 | func GetJsonNames(s reflect.Value) (res []string) { 70 | valType := s.Type() 71 | for i := 0; i < s.NumField(); i++ { 72 | field := valType.Field(i) 73 | tag := field.Tag 74 | jsonVal := tag.Get("json") 75 | if jsonVal != "" { 76 | components := strings.Split(jsonVal, ",") 77 | jsonName := components[0] 78 | res = append(res, jsonName) 79 | } else { 80 | res = append(res, field.Name) 81 | } 82 | } 83 | return res 84 | } 85 | -------------------------------------------------------------------------------- /domain/service_plan_metadata_test.go: -------------------------------------------------------------------------------- 1 | package domain_test 2 | 3 | import ( 4 | "encoding/json" 5 | "reflect" 6 | "sync" 7 | 8 | "code.cloudfoundry.org/brokerapi/v13" 9 | "code.cloudfoundry.org/brokerapi/v13/domain" 10 | . "github.com/onsi/ginkgo/v2" 11 | . "github.com/onsi/gomega" 12 | ) 13 | 14 | var _ = Describe("ServicePlanMetadata", func() { 15 | Describe("JSON encoding", func() { 16 | It("uses the correct keys", func() { 17 | metadata := domain.ServicePlanMetadata{ 18 | Bullets: []string{"test"}, 19 | DisplayName: "Some display name", 20 | } 21 | jsonString := `{"bullets":["test"],"displayName":"Some display name"}` 22 | 23 | Expect(json.Marshal(metadata)).To(MatchJSON(jsonString)) 24 | }) 25 | 26 | It("encodes the AdditionalMetadata fields in the metadata fields", func() { 27 | metadata := domain.ServicePlanMetadata{ 28 | Bullets: []string{"hello", "its me"}, 29 | DisplayName: "name", 30 | AdditionalMetadata: map[string]any{ 31 | "foo": "bar", 32 | "baz": 1, 33 | }, 34 | } 35 | jsonString := `{ 36 | "bullets":["hello", "its me"], 37 | "displayName":"name", 38 | "foo": "bar", 39 | "baz": 1 40 | }` 41 | 42 | Expect(json.Marshal(metadata)).To(MatchJSON(jsonString)) 43 | 44 | By("not mutating the AdditionalMetadata during custom JSON marshalling") 45 | Expect(len(metadata.AdditionalMetadata)).To(Equal(2)) 46 | }) 47 | 48 | It("it can marshal same structure in parallel requests", func() { 49 | metadata := domain.ServicePlanMetadata{ 50 | Bullets: []string{"hello", "its me"}, 51 | DisplayName: "name", 52 | AdditionalMetadata: map[string]any{ 53 | "foo": "bar", 54 | "baz": 1, 55 | }, 56 | } 57 | jsonString := `{ 58 | "bullets":["hello", "its me"], 59 | "displayName":"name", 60 | "foo": "bar", 61 | "baz": 1 62 | }` 63 | 64 | var wg sync.WaitGroup 65 | wg.Add(2) 66 | 67 | for i := 0; i < 2; i++ { 68 | go func() { 69 | defer wg.Done() 70 | defer GinkgoRecover() 71 | 72 | Expect(json.Marshal(metadata)).To(MatchJSON(jsonString)) 73 | }() 74 | } 75 | wg.Wait() 76 | }) 77 | 78 | It("returns an error when additional metadata is not marshallable", func() { 79 | metadata := domain.ServicePlanMetadata{ 80 | Bullets: []string{"hello", "its me"}, 81 | DisplayName: "name", 82 | AdditionalMetadata: map[string]any{ 83 | "foo": make(chan int), 84 | }, 85 | } 86 | _, err := json.Marshal(metadata) 87 | Expect(err).To(MatchError(ContainSubstring("unmarshallable content in AdditionalMetadata"))) 88 | }) 89 | }) 90 | 91 | Describe("JSON decoding", func() { 92 | It("sets the AdditionalMetadata from unrecognized fields", func() { 93 | metadata := domain.ServicePlanMetadata{} 94 | jsonString := `{"foo":["test"],"bar":"Some display name"}` 95 | 96 | err := json.Unmarshal([]byte(jsonString), &metadata) 97 | Expect(err).NotTo(HaveOccurred()) 98 | Expect(metadata.AdditionalMetadata["foo"]).To(Equal([]any{"test"})) 99 | Expect(metadata.AdditionalMetadata["bar"]).To(Equal("Some display name")) 100 | }) 101 | 102 | It("does not include convention fields into additional metadata", func() { 103 | metadata := domain.ServicePlanMetadata{} 104 | jsonString := `{"bullets":["test"],"displayName":"Some display name", "costs": [{"amount": {"usd": 649.0},"unit": "MONTHLY"}]}` 105 | 106 | err := json.Unmarshal([]byte(jsonString), &metadata) 107 | Expect(err).NotTo(HaveOccurred()) 108 | Expect(metadata.AdditionalMetadata).To(BeNil()) 109 | }) 110 | }) 111 | 112 | Describe("GetJsonNames", func() { 113 | It("Reflects JSON names from struct", func() { 114 | type Example1 struct { 115 | Foo int `json:"foo"` 116 | Bar string `yaml:"hello" json:"bar,omitempty"` 117 | Qux float64 118 | } 119 | 120 | s := Example1{} 121 | Expect(brokerapi.GetJsonNames(reflect.ValueOf(&s).Elem())).To( 122 | ConsistOf([]string{"foo", "bar", "Qux"})) 123 | }) 124 | }) 125 | }) 126 | -------------------------------------------------------------------------------- /failure_response.go: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2016-Present Pivotal Software, Inc. All rights reserved. 2 | // This program and the accompanying materials are made available under the terms of the under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. 3 | // You may obtain a copy of the License at 4 | // http://www.apache.org/licenses/LICENSE-2.0 5 | // Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. 6 | 7 | package brokerapi 8 | 9 | import ( 10 | "code.cloudfoundry.org/brokerapi/v13/domain/apiresponses" 11 | ) 12 | 13 | // Deprecated: Use code.cloudfoundry.org/brokerapi/v13/domain/apiresponses 14 | // FailureResponse can be returned from any of the `ServiceBroker` interface methods 15 | // which allow an error to be returned. Doing so will provide greater control over 16 | // the HTTP response. 17 | type FailureResponse = apiresponses.FailureResponse 18 | 19 | // Deprecated: Use code.cloudfoundry.org/brokerapi/v13/domain/apiresponses 20 | // NewFailureResponse returns an error of type FailureResponse. 21 | // err will by default be used as both a logging message and HTTP response description. 22 | // statusCode is the HTTP status code to be returned, must be 4xx or 5xx 23 | // loggerAction is a short description which will be used as the action if the error is logged. 24 | func NewFailureResponse(err error, statusCode int, loggerAction string) error { 25 | return apiresponses.NewFailureResponse(err, statusCode, loggerAction) 26 | } 27 | 28 | // Deprecated: Use code.cloudfoundry.org/brokerapi/v13/domain/apiresponses 29 | // FailureResponseBuilder provides a fluent set of methods to build a *FailureResponse. 30 | type FailureResponseBuilder = apiresponses.FailureResponseBuilder 31 | 32 | // Deprecated: Use code.cloudfoundry.org/brokerapi/v13/domain/apiresponses 33 | // NewFailureResponseBuilder returns a pointer to a newly instantiated FailureResponseBuilder 34 | // Accepts required arguments to create a FailureResponse. 35 | func NewFailureResponseBuilder(err error, statusCode int, loggerAction string) *FailureResponseBuilder { 36 | return (*FailureResponseBuilder)(apiresponses.NewFailureResponseBuilder(err, statusCode, loggerAction)) 37 | } 38 | -------------------------------------------------------------------------------- /failure_response_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2015-Present Pivotal Software, Inc. All rights reserved. 2 | 3 | // This program and the accompanying materials are made available under 4 | // the terms of the under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | 16 | package brokerapi_test 17 | 18 | import ( 19 | "errors" 20 | "log/slog" 21 | "net/http" 22 | 23 | "code.cloudfoundry.org/brokerapi/v13" 24 | "code.cloudfoundry.org/brokerapi/v13/domain/apiresponses" 25 | . "github.com/onsi/ginkgo/v2" 26 | . "github.com/onsi/gomega" 27 | "github.com/onsi/gomega/gbytes" 28 | ) 29 | 30 | var _ = Describe("FailureResponse", func() { 31 | Describe("ErrorResponse", func() { 32 | It("returns a ErrorResponse containing the error message", func() { 33 | failureResponse := asFailureResponse(brokerapi.NewFailureResponse(errors.New("my error message"), http.StatusForbidden, "log-key")) 34 | Expect(failureResponse.ErrorResponse()).To(Equal(brokerapi.ErrorResponse{ 35 | Description: "my error message", 36 | })) 37 | }) 38 | 39 | Context("when the error key is provided", func() { 40 | It("returns a ErrorResponse containing the error message and the error key", func() { 41 | failureResponse := asFailureResponse(brokerapi.NewFailureResponseBuilder(errors.New("my error message"), http.StatusForbidden, "log-key").WithErrorKey("error key").Build()) 42 | Expect(failureResponse.ErrorResponse()).To(Equal(brokerapi.ErrorResponse{ 43 | Description: "my error message", 44 | Error: "error key", 45 | })) 46 | }) 47 | }) 48 | 49 | Context("when created with empty response", func() { 50 | It("returns an EmptyResponse", func() { 51 | failureResponse := brokerapi.NewFailureResponseBuilder(errors.New("my error message"), http.StatusForbidden, "log-key").WithEmptyResponse().Build() 52 | Expect(failureResponse.ErrorResponse()).To(Equal(brokerapi.EmptyResponse{})) 53 | }) 54 | }) 55 | }) 56 | 57 | Describe("AppendErrorMessage", func() { 58 | It("returns the error with the additional error message included, with a non-empty body", func() { 59 | failureResponse := brokerapi.NewFailureResponseBuilder(errors.New("my error message"), http.StatusForbidden, "log-key").WithErrorKey("some-key").Build() 60 | Expect(failureResponse.Error()).To(Equal("my error message")) 61 | 62 | newError := failureResponse.AppendErrorMessage("and some more details") 63 | 64 | Expect(newError.Error()).To(Equal("my error message and some more details")) 65 | Expect(newError.ValidatedStatusCode(nil)).To(Equal(http.StatusForbidden)) 66 | Expect(newError.LoggerAction()).To(Equal(failureResponse.LoggerAction())) 67 | 68 | errorResponse, typeCast := newError.ErrorResponse().(brokerapi.ErrorResponse) 69 | Expect(typeCast).To(BeTrue()) 70 | Expect(errorResponse.Error).To(Equal("some-key")) 71 | Expect(errorResponse.Description).To(Equal("my error message and some more details")) 72 | }) 73 | 74 | It("returns the error with the additional error message included, with an empty body", func() { 75 | failureResponse := brokerapi.NewFailureResponseBuilder(errors.New("my error message"), http.StatusForbidden, "log-key").WithEmptyResponse().Build() 76 | Expect(failureResponse.Error()).To(Equal("my error message")) 77 | 78 | newError := failureResponse.AppendErrorMessage("and some more details") 79 | 80 | Expect(newError.Error()).To(Equal("my error message and some more details")) 81 | Expect(newError.ValidatedStatusCode(nil)).To(Equal(http.StatusForbidden)) 82 | Expect(newError.LoggerAction()).To(Equal(failureResponse.LoggerAction())) 83 | Expect(newError.ErrorResponse()).To(Equal(failureResponse.ErrorResponse())) 84 | }) 85 | }) 86 | 87 | Describe("ValidatedStatusCode", func() { 88 | It("returns the status code that was passed in", func() { 89 | failureResponse := asFailureResponse(brokerapi.NewFailureResponse(errors.New("my error message"), http.StatusForbidden, "log-key")) 90 | Expect(failureResponse.ValidatedStatusCode(nil)).To(Equal(http.StatusForbidden)) 91 | }) 92 | 93 | It("when error key is provided it returns the status code that was passed in", func() { 94 | failureResponse := brokerapi.NewFailureResponseBuilder(errors.New("my error message"), http.StatusForbidden, "log-key").WithErrorKey("error key").Build() 95 | Expect(failureResponse.ValidatedStatusCode(nil)).To(Equal(http.StatusForbidden)) 96 | }) 97 | 98 | Context("when the status code is invalid", func() { 99 | It("returns 500", func() { 100 | failureResponse := asFailureResponse(brokerapi.NewFailureResponse(errors.New("my error message"), 600, "log-key")) 101 | Expect(failureResponse.ValidatedStatusCode(nil)).To(Equal(http.StatusInternalServerError)) 102 | }) 103 | 104 | It("logs that the status has been changed", func() { 105 | log := gbytes.NewBuffer() 106 | logger := slog.New(slog.NewJSONHandler(log, nil)) 107 | failureResponse := asFailureResponse(brokerapi.NewFailureResponse(errors.New("my error message"), 600, "log-key")) 108 | failureResponse.ValidatedStatusCode(logger) 109 | Expect(log).To(gbytes.Say("Invalid failure http response code: 600, expected 4xx or 5xx, returning internal server error: 500.")) 110 | }) 111 | }) 112 | }) 113 | 114 | Describe("LoggerAction", func() { 115 | It("returns the logger action that was passed in", func() { 116 | failureResponse := brokerapi.NewFailureResponseBuilder(errors.New("my error message"), http.StatusForbidden, "log-key").WithErrorKey("error key").Build() 117 | Expect(failureResponse.LoggerAction()).To(Equal("log-key")) 118 | }) 119 | 120 | It("when error key is provided it returns the logger action that was passed in", func() { 121 | failureResponse := asFailureResponse(brokerapi.NewFailureResponse(errors.New("my error message"), http.StatusForbidden, "log-key")) 122 | Expect(failureResponse.LoggerAction()).To(Equal("log-key")) 123 | }) 124 | }) 125 | }) 126 | 127 | func asFailureResponse(err error) *apiresponses.FailureResponse { 128 | GinkgoHelper() 129 | Expect(err).To(BeAssignableToTypeOf(&apiresponses.FailureResponse{})) 130 | return err.(*apiresponses.FailureResponse) 131 | } 132 | -------------------------------------------------------------------------------- /fakes/staticcheck.conf: -------------------------------------------------------------------------------- 1 | checks = [] 2 | -------------------------------------------------------------------------------- /fixtures/async_bind_response.json: -------------------------------------------------------------------------------- 1 | { 2 | "operation":"0xDEADBEEF" 3 | } 4 | -------------------------------------------------------------------------------- /fixtures/async_required.json: -------------------------------------------------------------------------------- 1 | { 2 | "error": "AsyncRequired", 3 | "description": "This service plan requires client support for asynchronous service operations." 4 | } 5 | -------------------------------------------------------------------------------- /fixtures/binding.json: -------------------------------------------------------------------------------- 1 | { 2 | "credentials": { 3 | "host": "127.0.0.1", 4 | "port": 3000, 5 | "username": "batman", 6 | "password": "robin" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /fixtures/binding_with_experimental_volume_mounts.json: -------------------------------------------------------------------------------- 1 | { 2 | "credentials": { 3 | "host": "127.0.0.1", 4 | "port": 3000, 5 | "username": "batman", 6 | "password": "robin" 7 | }, 8 | "volume_mounts": [{ 9 | "container_path": "/dev/null", 10 | "mode": "rw", 11 | "private": { 12 | "driver": "driver", 13 | "group_id": "some-guid", 14 | "config": "{\"key\":\"value\"}" 15 | } 16 | }] 17 | } 18 | -------------------------------------------------------------------------------- /fixtures/binding_with_route_service.json: -------------------------------------------------------------------------------- 1 | { 2 | "credentials": { 3 | "host": "127.0.0.1", 4 | "port": 3000, 5 | "username": "batman", 6 | "password": "robin" 7 | }, 8 | "route_service_url": "some-route-url" 9 | } 10 | -------------------------------------------------------------------------------- /fixtures/binding_with_syslog.json: -------------------------------------------------------------------------------- 1 | { 2 | "credentials": { 3 | "host": "127.0.0.1", 4 | "port": 3000, 5 | "username": "batman", 6 | "password": "robin" 7 | }, 8 | "syslog_drain_url": "some-drain-url" 9 | } 10 | -------------------------------------------------------------------------------- /fixtures/binding_with_volume_mounts.json: -------------------------------------------------------------------------------- 1 | { 2 | "credentials": { 3 | "host": "127.0.0.1", 4 | "port": 3000, 5 | "username": "batman", 6 | "password": "robin" 7 | }, 8 | "volume_mounts": [{ 9 | "driver": "driver", 10 | "container_dir": "/dev/null", 11 | "mode": "rw", 12 | "device_type": "shared", 13 | "device": { 14 | "volume_id": "some-guid", 15 | "mount_config": { 16 | "key": "value" 17 | } 18 | } 19 | }] 20 | } 21 | -------------------------------------------------------------------------------- /fixtures/catalog.json: -------------------------------------------------------------------------------- 1 | { 2 | "services": [{ 3 | "bindable": true, 4 | "description": "Cassandra service for application development and testing", 5 | "id": "0A789746-596F-4CEA-BFAC-A0795DA056E3", 6 | "name": "p-cassandra", 7 | "plan_updateable": true, 8 | "plans": [{ 9 | "description": "The default Cassandra plan", 10 | "id": "plan-id", 11 | "name": "default", 12 | "metadata": { 13 | "displayName": "Cassandra" 14 | }, 15 | "maintenance_info": { 16 | "public": { 17 | "name": "foo" 18 | } 19 | }, 20 | "schemas": { 21 | "service_instance": { 22 | "create": { 23 | "parameters": { 24 | "$schema": "http://json-schema.org/draft-04/schema#", 25 | "type": "object", 26 | "properties": { 27 | "billing-account": { 28 | "description": "Billing account number used to charge use of shared fake server.", 29 | "type": "string" 30 | } 31 | } 32 | } 33 | }, 34 | "update": { 35 | "parameters": { 36 | "$schema": "http://json-schema.org/draft-04/schema#", 37 | "type": "object", 38 | "properties": { 39 | "billing-account": { 40 | "description": "Billing account number used to charge use of shared fake server.", 41 | "type": "string" 42 | } 43 | } 44 | } 45 | } 46 | }, 47 | "service_binding": { 48 | "create": { 49 | "parameters": { 50 | "$schema": "http://json-schema.org/draft-04/schema#", 51 | "type": "object", 52 | "properties": { 53 | "billing-account": { 54 | "description": "Billing account number used to charge use of shared fake server.", 55 | "type": "string" 56 | } 57 | } 58 | } 59 | } 60 | } 61 | } 62 | }], 63 | "metadata": { 64 | "displayName": "Cassandra", 65 | "longDescription": "Long description", 66 | "documentationUrl": "http://thedocs.com", 67 | "supportUrl": "http://helpme.no" 68 | }, 69 | "tags": [ 70 | "pivotal", 71 | "cassandra" 72 | ] 73 | }] 74 | } 75 | -------------------------------------------------------------------------------- /fixtures/get_instance.json: -------------------------------------------------------------------------------- 1 | { 2 | "service_id" : "0A789746-596F-4CEA-BFAC-A0795DA056E3", 3 | "plan_id" : "plan-id", 4 | "dashboard_url" : "https://example.com/dashboard/some-instance", 5 | "parameters": { 6 | "param1" : "value1" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /fixtures/instance_limit_error.json: -------------------------------------------------------------------------------- 1 | { 2 | "description": "instance limit for this service has been reached" 3 | } -------------------------------------------------------------------------------- /fixtures/last_operation_succeeded.json: -------------------------------------------------------------------------------- 1 | { 2 | "state": "succeeded", 3 | "description": "some description" 4 | } 5 | -------------------------------------------------------------------------------- /fixtures/operation_data_response.json: -------------------------------------------------------------------------------- 1 | { 2 | "operation": "some-operation-data" 3 | } 4 | -------------------------------------------------------------------------------- /fixtures/provisioning.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /fixtures/provisioning_with_dashboard.json: -------------------------------------------------------------------------------- 1 | { 2 | "dashboard_url": "some-dashboard-url" 3 | } 4 | -------------------------------------------------------------------------------- /fixtures/updating_with_dashboard.json: -------------------------------------------------------------------------------- 1 | { 2 | "dashboard_url": "some-dashboard-url" 3 | } 4 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module code.cloudfoundry.org/brokerapi/v13 2 | 3 | go 1.23.0 4 | 5 | toolchain go1.24.1 6 | 7 | require ( 8 | github.com/google/uuid v1.6.0 9 | github.com/maxbrunsfeld/counterfeiter/v6 v6.9.0 10 | github.com/onsi/ginkgo/v2 v2.23.4 11 | github.com/onsi/gomega v1.37.0 12 | honnef.co/go/tools v0.6.1 13 | ) 14 | 15 | require ( 16 | github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c // indirect 17 | github.com/go-logr/logr v1.4.2 // indirect 18 | github.com/go-task/slim-sprig/v3 v3.0.0 // indirect 19 | github.com/google/go-cmp v0.7.0 // indirect 20 | github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 // indirect 21 | go.uber.org/automaxprocs v1.6.0 // indirect 22 | golang.org/x/exp/typeparams v0.0.0-20231108232855-2478ac86f678 // indirect 23 | golang.org/x/mod v0.24.0 // indirect 24 | golang.org/x/net v0.38.0 // indirect 25 | golang.org/x/sync v0.12.0 // indirect 26 | golang.org/x/sys v0.32.0 // indirect 27 | golang.org/x/text v0.23.0 // indirect 28 | golang.org/x/tools v0.31.0 // indirect 29 | gopkg.in/yaml.v3 v3.0.1 // indirect 30 | ) 31 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c h1:pxW6RcqyfI9/kWtOwnv/G+AzdKuy2ZrqINhenH4HyNs= 2 | github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= 3 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 4 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 5 | github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= 6 | github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= 7 | github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= 8 | github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= 9 | github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 10 | github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 11 | github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8= 12 | github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= 13 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 14 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 15 | github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= 16 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 17 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 18 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 19 | github.com/maxbrunsfeld/counterfeiter/v6 v6.9.0 h1:ERhc+PJKEyqWQnKu7/K0frSVGFihYYImqNdqP5r0cN0= 20 | github.com/maxbrunsfeld/counterfeiter/v6 v6.9.0/go.mod h1:tU2wQdIyJ7fib/YXxFR0dgLlFz3yl4p275UfUKmDFjk= 21 | github.com/onsi/ginkgo/v2 v2.23.4 h1:ktYTpKJAVZnDT4VjxSbiBenUjmlL/5QkBEocaWXiQus= 22 | github.com/onsi/ginkgo/v2 v2.23.4/go.mod h1:Bt66ApGPBFzHyR+JO10Zbt0Gsp4uWxu5mIOTusL46e8= 23 | github.com/onsi/gomega v1.37.0 h1:CdEG8g0S133B4OswTDC/5XPSzE1OeP29QOioj2PID2Y= 24 | github.com/onsi/gomega v1.37.0/go.mod h1:8D9+Txp43QWKhM24yyOBEdpkzN8FvJyAwecBgsU4KU0= 25 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 26 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 27 | github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g= 28 | github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U= 29 | github.com/sclevine/spec v1.4.0 h1:z/Q9idDcay5m5irkZ28M7PtQM4aOISzOpj4bUPkDee8= 30 | github.com/sclevine/spec v1.4.0/go.mod h1:LvpgJaFyvQzRvc1kaDs0bulYwzC70PbiYjC4QnFHkOM= 31 | github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= 32 | github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 33 | go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs= 34 | go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8= 35 | golang.org/x/exp/typeparams v0.0.0-20231108232855-2478ac86f678 h1:1P7xPZEwZMoBoz0Yze5Nx2/4pxj6nw9ZqHWXqP0iRgQ= 36 | golang.org/x/exp/typeparams v0.0.0-20231108232855-2478ac86f678/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk= 37 | golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU= 38 | golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= 39 | golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= 40 | golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= 41 | golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= 42 | golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 43 | golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= 44 | golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 45 | golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= 46 | golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= 47 | golang.org/x/tools v0.31.0 h1:0EedkvKDbh+qistFTd0Bcwe/YLh4vHwWEkiI0toFIBU= 48 | golang.org/x/tools v0.31.0/go.mod h1:naFTU+Cev749tSJRXJlna0T3WxKvb1kWEx15xA4SdmQ= 49 | google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM= 50 | google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= 51 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 52 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 53 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 54 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 55 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 56 | honnef.co/go/tools v0.6.1 h1:R094WgE8K4JirYjBaOpz/AvTyUu/3wbmAoskKN/pxTI= 57 | honnef.co/go/tools v0.6.1/go.mod h1:3puzxxljPCe8RGJX7BIy1plGbxEOZni5mR2aXe3/uk4= 58 | -------------------------------------------------------------------------------- /handlers/api_handler.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | "log/slog" 8 | "net/http" 9 | 10 | "code.cloudfoundry.org/brokerapi/v13/domain" 11 | "code.cloudfoundry.org/brokerapi/v13/internal/blog" 12 | ) 13 | 14 | const ( 15 | invalidServiceDetailsErrorKey = "invalid-service-details" 16 | serviceIdMissingKey = "service-id-missing" 17 | planIdMissingKey = "plan-id-missing" 18 | unknownErrorKey = "unknown-error" 19 | ) 20 | 21 | var ( 22 | serviceIdError = errors.New("service_id missing") 23 | planIdError = errors.New("plan_id missing") 24 | invalidServiceIDError = errors.New("service-id not in the catalog") 25 | invalidPlanIDError = errors.New("plan-id not in the catalog") 26 | ) 27 | 28 | type APIHandler struct { 29 | serviceBroker domain.ServiceBroker 30 | logger blog.Blog 31 | } 32 | 33 | func NewApiHandler(broker domain.ServiceBroker, logger *slog.Logger) APIHandler { 34 | return APIHandler{serviceBroker: broker, logger: blog.New(logger)} 35 | } 36 | 37 | func (h APIHandler) respond(w http.ResponseWriter, status int, requestIdentity string, response any) { 38 | w.Header().Set("Content-Type", "application/json") 39 | if requestIdentity != "" { 40 | w.Header().Set("X-Broker-API-Request-Identity", requestIdentity) 41 | } 42 | w.WriteHeader(status) 43 | 44 | encoder := json.NewEncoder(w) 45 | encoder.SetEscapeHTML(false) 46 | err := encoder.Encode(response) 47 | if err != nil { 48 | h.logger.Error("encoding response", err, slog.Int("status", status), slog.Any("response", response)) 49 | } 50 | } 51 | 52 | type brokerVersion struct { 53 | Major int 54 | Minor int 55 | } 56 | 57 | func getAPIVersion(req *http.Request) brokerVersion { 58 | var version brokerVersion 59 | apiVersion := req.Header.Get("X-Broker-API-Version") 60 | 61 | fmt.Sscanf(apiVersion, "%d.%d", &version.Major, &version.Minor) 62 | 63 | return version 64 | } 65 | -------------------------------------------------------------------------------- /handlers/bind.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "log/slog" 7 | "net/http" 8 | 9 | "code.cloudfoundry.org/brokerapi/v13/domain" 10 | "code.cloudfoundry.org/brokerapi/v13/domain/apiresponses" 11 | "code.cloudfoundry.org/brokerapi/v13/internal/blog" 12 | "code.cloudfoundry.org/brokerapi/v13/middlewares" 13 | ) 14 | 15 | const ( 16 | bindLogKey = "bind" 17 | invalidBindDetailsErrorKey = "invalid-bind-details" 18 | ) 19 | 20 | func (h APIHandler) Bind(w http.ResponseWriter, req *http.Request) { 21 | instanceID := req.PathValue("instance_id") 22 | bindingID := req.PathValue("binding_id") 23 | 24 | logger := h.logger.Session(req.Context(), bindLogKey, blog.InstanceID(instanceID), blog.BindingID(bindingID)) 25 | 26 | version := getAPIVersion(req) 27 | asyncAllowed := false 28 | if version.Minor >= 14 { 29 | asyncAllowed = req.FormValue("accepts_incomplete") == "true" 30 | } 31 | 32 | requestId := fmt.Sprintf("%v", req.Context().Value(middlewares.RequestIdentityKey)) 33 | 34 | var details domain.BindDetails 35 | if err := json.NewDecoder(req.Body).Decode(&details); err != nil { 36 | logger.Error(invalidBindDetailsErrorKey, err) 37 | h.respond(w, http.StatusUnprocessableEntity, requestId, apiresponses.ErrorResponse{ 38 | Description: err.Error(), 39 | }) 40 | return 41 | } 42 | 43 | if details.ServiceID == "" { 44 | logger.Error(serviceIdMissingKey, serviceIdError) 45 | h.respond(w, http.StatusBadRequest, requestId, apiresponses.ErrorResponse{ 46 | Description: serviceIdError.Error(), 47 | }) 48 | return 49 | } 50 | 51 | if details.PlanID == "" { 52 | logger.Error(planIdMissingKey, planIdError) 53 | h.respond(w, http.StatusBadRequest, requestId, apiresponses.ErrorResponse{ 54 | Description: planIdError.Error(), 55 | }) 56 | return 57 | } 58 | 59 | binding, err := h.serviceBroker.Bind(req.Context(), instanceID, bindingID, details, asyncAllowed) 60 | if err != nil { 61 | switch err := err.(type) { 62 | case *apiresponses.FailureResponse: 63 | statusCode := err.ValidatedStatusCode(slog.New(logger)) 64 | errorResponse := err.ErrorResponse() 65 | if err == apiresponses.ErrInstanceDoesNotExist { 66 | // work around ErrInstanceDoesNotExist having different pre-refactor behaviour to other actions 67 | errorResponse = apiresponses.ErrorResponse{ 68 | Description: err.Error(), 69 | } 70 | statusCode = http.StatusNotFound 71 | } 72 | logger.Error(err.LoggerAction(), err) 73 | h.respond(w, statusCode, requestId, errorResponse) 74 | default: 75 | logger.Error(unknownErrorKey, err) 76 | h.respond(w, http.StatusInternalServerError, requestId, apiresponses.ErrorResponse{ 77 | Description: err.Error(), 78 | }) 79 | } 80 | return 81 | } 82 | 83 | var metadata any 84 | if !binding.Metadata.IsEmpty() { 85 | metadata = binding.Metadata 86 | } 87 | 88 | if binding.AlreadyExists { 89 | h.respond(w, http.StatusOK, requestId, apiresponses.BindingResponse{ 90 | Credentials: binding.Credentials, 91 | SyslogDrainURL: binding.SyslogDrainURL, 92 | RouteServiceURL: binding.RouteServiceURL, 93 | VolumeMounts: binding.VolumeMounts, 94 | BackupAgentURL: binding.BackupAgentURL, 95 | Endpoints: binding.Endpoints, 96 | Metadata: metadata, 97 | }) 98 | return 99 | } 100 | 101 | if binding.IsAsync { 102 | h.respond(w, http.StatusAccepted, requestId, apiresponses.AsyncBindResponse{ 103 | OperationData: binding.OperationData, 104 | }) 105 | return 106 | } 107 | 108 | if version.Minor == 8 || version.Minor == 9 { 109 | experimentalVols := []domain.ExperimentalVolumeMount{} 110 | 111 | for _, vol := range binding.VolumeMounts { 112 | experimentalConfig, err := json.Marshal(vol.Device.MountConfig) 113 | if err != nil { 114 | logger.Error(unknownErrorKey, err) 115 | h.respond(w, http.StatusInternalServerError, requestId, apiresponses.ErrorResponse{Description: err.Error()}) 116 | return 117 | } 118 | 119 | experimentalVols = append(experimentalVols, domain.ExperimentalVolumeMount{ 120 | ContainerPath: vol.ContainerDir, 121 | Mode: vol.Mode, 122 | Private: domain.ExperimentalVolumeMountPrivate{ 123 | Driver: vol.Driver, 124 | GroupID: vol.Device.VolumeId, 125 | Config: string(experimentalConfig), 126 | }, 127 | }) 128 | } 129 | 130 | experimentalBinding := apiresponses.ExperimentalVolumeMountBindingResponse{ 131 | Credentials: binding.Credentials, 132 | RouteServiceURL: binding.RouteServiceURL, 133 | SyslogDrainURL: binding.SyslogDrainURL, 134 | VolumeMounts: experimentalVols, 135 | BackupAgentURL: binding.BackupAgentURL, 136 | } 137 | h.respond(w, http.StatusCreated, requestId, experimentalBinding) 138 | return 139 | } 140 | 141 | h.respond(w, http.StatusCreated, requestId, apiresponses.BindingResponse{ 142 | Credentials: binding.Credentials, 143 | SyslogDrainURL: binding.SyslogDrainURL, 144 | RouteServiceURL: binding.RouteServiceURL, 145 | VolumeMounts: binding.VolumeMounts, 146 | BackupAgentURL: binding.BackupAgentURL, 147 | Endpoints: binding.Endpoints, 148 | Metadata: metadata, 149 | }) 150 | } 151 | -------------------------------------------------------------------------------- /handlers/catalog.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "fmt" 5 | "log/slog" 6 | "net/http" 7 | 8 | "code.cloudfoundry.org/brokerapi/v13/domain/apiresponses" 9 | "code.cloudfoundry.org/brokerapi/v13/middlewares" 10 | ) 11 | 12 | const getCatalogLogKey = "getCatalog" 13 | 14 | func (h APIHandler) Catalog(w http.ResponseWriter, req *http.Request) { 15 | logger := h.logger.Session(req.Context(), getCatalogLogKey) 16 | requestId := fmt.Sprintf("%v", req.Context().Value(middlewares.RequestIdentityKey)) 17 | 18 | services, err := h.serviceBroker.Services(req.Context()) 19 | if err != nil { 20 | switch err := err.(type) { 21 | case *apiresponses.FailureResponse: 22 | logger.Error(err.LoggerAction(), err) 23 | h.respond(w, err.ValidatedStatusCode(slog.New(logger)), requestId, err.ErrorResponse()) 24 | default: 25 | logger.Error(unknownErrorKey, err) 26 | h.respond(w, http.StatusInternalServerError, requestId, apiresponses.ErrorResponse{ 27 | Description: err.Error(), 28 | }) 29 | } 30 | return 31 | } 32 | 33 | catalog := apiresponses.CatalogResponse{ 34 | Services: services, 35 | } 36 | 37 | h.respond(w, http.StatusOK, requestId, catalog) 38 | } 39 | -------------------------------------------------------------------------------- /handlers/catalog_test.go: -------------------------------------------------------------------------------- 1 | package handlers_test 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "log/slog" 7 | "net/http" 8 | 9 | "code.cloudfoundry.org/brokerapi/v13/domain" 10 | "code.cloudfoundry.org/brokerapi/v13/domain/apiresponses" 11 | brokerFakes "code.cloudfoundry.org/brokerapi/v13/fakes" 12 | "code.cloudfoundry.org/brokerapi/v13/handlers" 13 | "code.cloudfoundry.org/brokerapi/v13/handlers/fakes" 14 | "code.cloudfoundry.org/brokerapi/v13/middlewares" 15 | . "github.com/onsi/ginkgo/v2" 16 | . "github.com/onsi/gomega" 17 | ) 18 | 19 | var _ = Describe("Services", func() { 20 | var ( 21 | fakeServiceBroker *brokerFakes.AutoFakeServiceBroker 22 | fakeResponseWriter *fakes.FakeResponseWriter 23 | apiHandler handlers.APIHandler 24 | 25 | serviceID string 26 | ) 27 | 28 | BeforeEach(func() { 29 | serviceID = "a-service" 30 | 31 | fakeServiceBroker = new(brokerFakes.AutoFakeServiceBroker) 32 | 33 | apiHandler = handlers.NewApiHandler(fakeServiceBroker, slog.New(slog.NewJSONHandler(GinkgoWriter, nil))) 34 | 35 | fakeResponseWriter = new(fakes.FakeResponseWriter) 36 | fakeResponseWriter.HeaderReturns(http.Header{}) 37 | }) 38 | 39 | It("responds with OK when broker can retrieve the services catalog", func() { 40 | request := newServicesRequest() 41 | expectedServices := []domain.Service{ 42 | { 43 | ID: serviceID, 44 | Name: serviceID, 45 | Description: "muy bien", 46 | }, 47 | } 48 | 49 | fakeServiceBroker.ServicesReturns(expectedServices, nil) 50 | 51 | apiHandler.Catalog(fakeResponseWriter, request) 52 | 53 | statusCode := fakeResponseWriter.WriteHeaderArgsForCall(0) 54 | Expect(statusCode).To(Equal(http.StatusOK)) 55 | body := fakeResponseWriter.WriteArgsForCall(0) 56 | Expect(body).ToNot(BeEmpty()) 57 | }) 58 | 59 | It("responds with InternalServerError when services catalog returns unknown error", func() { 60 | request := newServicesRequest() 61 | 62 | fakeServiceBroker.ServicesReturns(nil, errors.New("some error")) 63 | 64 | apiHandler.Catalog(fakeResponseWriter, request) 65 | 66 | statusCode := fakeResponseWriter.WriteHeaderArgsForCall(0) 67 | Expect(statusCode).To(Equal(http.StatusInternalServerError)) 68 | body := fakeResponseWriter.WriteArgsForCall(0) 69 | Expect(body).To(MatchJSON(`{"description":"some error"}`)) 70 | }) 71 | 72 | It("responds with status code set in the FailureResponse when services catalog returns it", func() { 73 | request := newServicesRequest() 74 | 75 | fakeServiceBroker.ServicesReturns( 76 | nil, 77 | apiresponses.NewFailureResponse( 78 | errors.New("TODO"), 79 | http.StatusNotImplemented, 80 | http.StatusText(http.StatusNotImplemented), 81 | ), 82 | ) 83 | 84 | apiHandler.Catalog(fakeResponseWriter, request) 85 | 86 | statusCode := fakeResponseWriter.WriteHeaderArgsForCall(0) 87 | Expect(statusCode).To(Equal(http.StatusNotImplemented)) 88 | body := fakeResponseWriter.WriteArgsForCall(0) 89 | Expect(body).To(MatchJSON(`{"description":"TODO"}`)) 90 | }) 91 | }) 92 | 93 | func newServicesRequest() *http.Request { 94 | request, err := http.NewRequest( 95 | "GET", 96 | "https://broker.url/v2/catalog", 97 | nil, 98 | ) 99 | Expect(err).ToNot(HaveOccurred()) 100 | request.Header.Add("X-Broker-API-Version", "2.13") 101 | 102 | newCtx := context.WithValue(request.Context(), middlewares.CorrelationIDKey, "fake-correlation-id") 103 | request = request.WithContext(newCtx) 104 | return request 105 | } 106 | -------------------------------------------------------------------------------- /handlers/deprovision.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "fmt" 5 | "log/slog" 6 | "net/http" 7 | 8 | "code.cloudfoundry.org/brokerapi/v13/domain" 9 | "code.cloudfoundry.org/brokerapi/v13/domain/apiresponses" 10 | "code.cloudfoundry.org/brokerapi/v13/internal/blog" 11 | "code.cloudfoundry.org/brokerapi/v13/middlewares" 12 | ) 13 | 14 | const deprovisionLogKey = "deprovision" 15 | 16 | func (h APIHandler) Deprovision(w http.ResponseWriter, req *http.Request) { 17 | instanceID := req.PathValue("instance_id") 18 | 19 | logger := h.logger.Session(req.Context(), deprovisionLogKey, blog.InstanceID(instanceID)) 20 | 21 | details := domain.DeprovisionDetails{ 22 | PlanID: req.FormValue("plan_id"), 23 | ServiceID: req.FormValue("service_id"), 24 | Force: req.FormValue("force") == "true", 25 | } 26 | 27 | requestId := fmt.Sprintf("%v", req.Context().Value(middlewares.RequestIdentityKey)) 28 | 29 | if details.ServiceID == "" { 30 | h.respond(w, http.StatusBadRequest, requestId, apiresponses.ErrorResponse{ 31 | Description: serviceIdError.Error(), 32 | }) 33 | logger.Error(serviceIdMissingKey, serviceIdError) 34 | return 35 | } 36 | 37 | if details.PlanID == "" { 38 | h.respond(w, http.StatusBadRequest, requestId, apiresponses.ErrorResponse{ 39 | Description: planIdError.Error(), 40 | }) 41 | logger.Error(planIdMissingKey, planIdError) 42 | return 43 | } 44 | 45 | asyncAllowed := req.FormValue("accepts_incomplete") == "true" 46 | 47 | deprovisionSpec, err := h.serviceBroker.Deprovision(req.Context(), instanceID, details, asyncAllowed) 48 | if err != nil { 49 | switch err := err.(type) { 50 | case *apiresponses.FailureResponse: 51 | logger.Error(err.LoggerAction(), err) 52 | h.respond(w, err.ValidatedStatusCode(slog.New(logger)), requestId, err.ErrorResponse()) 53 | default: 54 | logger.Error(unknownErrorKey, err) 55 | h.respond(w, http.StatusInternalServerError, requestId, apiresponses.ErrorResponse{ 56 | Description: err.Error(), 57 | }) 58 | } 59 | return 60 | } 61 | 62 | if deprovisionSpec.IsAsync { 63 | h.respond(w, http.StatusAccepted, requestId, apiresponses.DeprovisionResponse{OperationData: deprovisionSpec.OperationData}) 64 | } else { 65 | h.respond(w, http.StatusOK, requestId, apiresponses.EmptyResponse{}) 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /handlers/fakes/fake_response_writer.go: -------------------------------------------------------------------------------- 1 | // Code generated by counterfeiter. DO NOT EDIT. 2 | package fakes 3 | 4 | import ( 5 | "net/http" 6 | "sync" 7 | ) 8 | 9 | type FakeResponseWriter struct { 10 | HeaderStub func() http.Header 11 | headerMutex sync.RWMutex 12 | headerArgsForCall []struct { 13 | } 14 | headerReturns struct { 15 | result1 http.Header 16 | } 17 | headerReturnsOnCall map[int]struct { 18 | result1 http.Header 19 | } 20 | WriteStub func([]byte) (int, error) 21 | writeMutex sync.RWMutex 22 | writeArgsForCall []struct { 23 | arg1 []byte 24 | } 25 | writeReturns struct { 26 | result1 int 27 | result2 error 28 | } 29 | writeReturnsOnCall map[int]struct { 30 | result1 int 31 | result2 error 32 | } 33 | WriteHeaderStub func(int) 34 | writeHeaderMutex sync.RWMutex 35 | writeHeaderArgsForCall []struct { 36 | arg1 int 37 | } 38 | invocations map[string][][]interface{} 39 | invocationsMutex sync.RWMutex 40 | } 41 | 42 | func (fake *FakeResponseWriter) Header() http.Header { 43 | fake.headerMutex.Lock() 44 | ret, specificReturn := fake.headerReturnsOnCall[len(fake.headerArgsForCall)] 45 | fake.headerArgsForCall = append(fake.headerArgsForCall, struct { 46 | }{}) 47 | stub := fake.HeaderStub 48 | fakeReturns := fake.headerReturns 49 | fake.recordInvocation("Header", []interface{}{}) 50 | fake.headerMutex.Unlock() 51 | if stub != nil { 52 | return stub() 53 | } 54 | if specificReturn { 55 | return ret.result1 56 | } 57 | return fakeReturns.result1 58 | } 59 | 60 | func (fake *FakeResponseWriter) HeaderCallCount() int { 61 | fake.headerMutex.RLock() 62 | defer fake.headerMutex.RUnlock() 63 | return len(fake.headerArgsForCall) 64 | } 65 | 66 | func (fake *FakeResponseWriter) HeaderCalls(stub func() http.Header) { 67 | fake.headerMutex.Lock() 68 | defer fake.headerMutex.Unlock() 69 | fake.HeaderStub = stub 70 | } 71 | 72 | func (fake *FakeResponseWriter) HeaderReturns(result1 http.Header) { 73 | fake.headerMutex.Lock() 74 | defer fake.headerMutex.Unlock() 75 | fake.HeaderStub = nil 76 | fake.headerReturns = struct { 77 | result1 http.Header 78 | }{result1} 79 | } 80 | 81 | func (fake *FakeResponseWriter) HeaderReturnsOnCall(i int, result1 http.Header) { 82 | fake.headerMutex.Lock() 83 | defer fake.headerMutex.Unlock() 84 | fake.HeaderStub = nil 85 | if fake.headerReturnsOnCall == nil { 86 | fake.headerReturnsOnCall = make(map[int]struct { 87 | result1 http.Header 88 | }) 89 | } 90 | fake.headerReturnsOnCall[i] = struct { 91 | result1 http.Header 92 | }{result1} 93 | } 94 | 95 | func (fake *FakeResponseWriter) Write(arg1 []byte) (int, error) { 96 | var arg1Copy []byte 97 | if arg1 != nil { 98 | arg1Copy = make([]byte, len(arg1)) 99 | copy(arg1Copy, arg1) 100 | } 101 | fake.writeMutex.Lock() 102 | ret, specificReturn := fake.writeReturnsOnCall[len(fake.writeArgsForCall)] 103 | fake.writeArgsForCall = append(fake.writeArgsForCall, struct { 104 | arg1 []byte 105 | }{arg1Copy}) 106 | stub := fake.WriteStub 107 | fakeReturns := fake.writeReturns 108 | fake.recordInvocation("Write", []interface{}{arg1Copy}) 109 | fake.writeMutex.Unlock() 110 | if stub != nil { 111 | return stub(arg1) 112 | } 113 | if specificReturn { 114 | return ret.result1, ret.result2 115 | } 116 | return fakeReturns.result1, fakeReturns.result2 117 | } 118 | 119 | func (fake *FakeResponseWriter) WriteCallCount() int { 120 | fake.writeMutex.RLock() 121 | defer fake.writeMutex.RUnlock() 122 | return len(fake.writeArgsForCall) 123 | } 124 | 125 | func (fake *FakeResponseWriter) WriteCalls(stub func([]byte) (int, error)) { 126 | fake.writeMutex.Lock() 127 | defer fake.writeMutex.Unlock() 128 | fake.WriteStub = stub 129 | } 130 | 131 | func (fake *FakeResponseWriter) WriteArgsForCall(i int) []byte { 132 | fake.writeMutex.RLock() 133 | defer fake.writeMutex.RUnlock() 134 | argsForCall := fake.writeArgsForCall[i] 135 | return argsForCall.arg1 136 | } 137 | 138 | func (fake *FakeResponseWriter) WriteReturns(result1 int, result2 error) { 139 | fake.writeMutex.Lock() 140 | defer fake.writeMutex.Unlock() 141 | fake.WriteStub = nil 142 | fake.writeReturns = struct { 143 | result1 int 144 | result2 error 145 | }{result1, result2} 146 | } 147 | 148 | func (fake *FakeResponseWriter) WriteReturnsOnCall(i int, result1 int, result2 error) { 149 | fake.writeMutex.Lock() 150 | defer fake.writeMutex.Unlock() 151 | fake.WriteStub = nil 152 | if fake.writeReturnsOnCall == nil { 153 | fake.writeReturnsOnCall = make(map[int]struct { 154 | result1 int 155 | result2 error 156 | }) 157 | } 158 | fake.writeReturnsOnCall[i] = struct { 159 | result1 int 160 | result2 error 161 | }{result1, result2} 162 | } 163 | 164 | func (fake *FakeResponseWriter) WriteHeader(arg1 int) { 165 | fake.writeHeaderMutex.Lock() 166 | fake.writeHeaderArgsForCall = append(fake.writeHeaderArgsForCall, struct { 167 | arg1 int 168 | }{arg1}) 169 | stub := fake.WriteHeaderStub 170 | fake.recordInvocation("WriteHeader", []interface{}{arg1}) 171 | fake.writeHeaderMutex.Unlock() 172 | if stub != nil { 173 | fake.WriteHeaderStub(arg1) 174 | } 175 | } 176 | 177 | func (fake *FakeResponseWriter) WriteHeaderCallCount() int { 178 | fake.writeHeaderMutex.RLock() 179 | defer fake.writeHeaderMutex.RUnlock() 180 | return len(fake.writeHeaderArgsForCall) 181 | } 182 | 183 | func (fake *FakeResponseWriter) WriteHeaderCalls(stub func(int)) { 184 | fake.writeHeaderMutex.Lock() 185 | defer fake.writeHeaderMutex.Unlock() 186 | fake.WriteHeaderStub = stub 187 | } 188 | 189 | func (fake *FakeResponseWriter) WriteHeaderArgsForCall(i int) int { 190 | fake.writeHeaderMutex.RLock() 191 | defer fake.writeHeaderMutex.RUnlock() 192 | argsForCall := fake.writeHeaderArgsForCall[i] 193 | return argsForCall.arg1 194 | } 195 | 196 | func (fake *FakeResponseWriter) Invocations() map[string][][]interface{} { 197 | fake.invocationsMutex.RLock() 198 | defer fake.invocationsMutex.RUnlock() 199 | fake.headerMutex.RLock() 200 | defer fake.headerMutex.RUnlock() 201 | fake.writeMutex.RLock() 202 | defer fake.writeMutex.RUnlock() 203 | fake.writeHeaderMutex.RLock() 204 | defer fake.writeHeaderMutex.RUnlock() 205 | copiedInvocations := map[string][][]interface{}{} 206 | for key, value := range fake.invocations { 207 | copiedInvocations[key] = value 208 | } 209 | return copiedInvocations 210 | } 211 | 212 | func (fake *FakeResponseWriter) recordInvocation(key string, args []interface{}) { 213 | fake.invocationsMutex.Lock() 214 | defer fake.invocationsMutex.Unlock() 215 | if fake.invocations == nil { 216 | fake.invocations = map[string][][]interface{}{} 217 | } 218 | if fake.invocations[key] == nil { 219 | fake.invocations[key] = [][]interface{}{} 220 | } 221 | fake.invocations[key] = append(fake.invocations[key], args) 222 | } 223 | 224 | var _ http.ResponseWriter = new(FakeResponseWriter) 225 | -------------------------------------------------------------------------------- /handlers/get_binding.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "log/slog" 7 | "net/http" 8 | 9 | "code.cloudfoundry.org/brokerapi/v13/domain" 10 | "code.cloudfoundry.org/brokerapi/v13/domain/apiresponses" 11 | "code.cloudfoundry.org/brokerapi/v13/internal/blog" 12 | "code.cloudfoundry.org/brokerapi/v13/middlewares" 13 | ) 14 | 15 | const getBindLogKey = "getBinding" 16 | 17 | func (h APIHandler) GetBinding(w http.ResponseWriter, req *http.Request) { 18 | instanceID := req.PathValue("instance_id") 19 | bindingID := req.PathValue("binding_id") 20 | 21 | logger := h.logger.Session(req.Context(), getBindLogKey, blog.InstanceID(instanceID), blog.BindingID(bindingID)) 22 | 23 | requestId := fmt.Sprintf("%v", req.Context().Value(middlewares.RequestIdentityKey)) 24 | 25 | version := getAPIVersion(req) 26 | if version.Minor < 14 { 27 | err := errors.New("get binding endpoint only supported starting with OSB version 2.14") 28 | h.respond(w, http.StatusPreconditionFailed, requestId, apiresponses.ErrorResponse{ 29 | Description: err.Error(), 30 | }) 31 | logger.Error(middlewares.ApiVersionInvalidKey, err) 32 | return 33 | } 34 | 35 | details := domain.FetchBindingDetails{ 36 | ServiceID: req.URL.Query().Get("service_id"), 37 | PlanID: req.URL.Query().Get("plan_id"), 38 | } 39 | 40 | binding, err := h.serviceBroker.GetBinding(req.Context(), instanceID, bindingID, details) 41 | if err != nil { 42 | switch err := err.(type) { 43 | case *apiresponses.FailureResponse: 44 | logger.Error(err.LoggerAction(), err) 45 | h.respond(w, err.ValidatedStatusCode(slog.New(logger)), requestId, err.ErrorResponse()) 46 | default: 47 | logger.Error(unknownErrorKey, err) 48 | h.respond(w, http.StatusInternalServerError, requestId, apiresponses.ErrorResponse{ 49 | Description: err.Error(), 50 | }) 51 | } 52 | return 53 | } 54 | 55 | var metadata any 56 | if !binding.Metadata.IsEmpty() { 57 | metadata = binding.Metadata 58 | } 59 | 60 | h.respond(w, http.StatusOK, requestId, apiresponses.GetBindingResponse{ 61 | BindingResponse: apiresponses.BindingResponse{ 62 | Credentials: binding.Credentials, 63 | SyslogDrainURL: binding.SyslogDrainURL, 64 | RouteServiceURL: binding.RouteServiceURL, 65 | VolumeMounts: binding.VolumeMounts, 66 | Endpoints: binding.Endpoints, 67 | Metadata: metadata, 68 | }, 69 | Parameters: binding.Parameters, 70 | }) 71 | } 72 | -------------------------------------------------------------------------------- /handlers/get_instance.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "log/slog" 7 | "net/http" 8 | 9 | "code.cloudfoundry.org/brokerapi/v13/internal/blog" 10 | 11 | "code.cloudfoundry.org/brokerapi/v13/domain" 12 | "code.cloudfoundry.org/brokerapi/v13/domain/apiresponses" 13 | "code.cloudfoundry.org/brokerapi/v13/middlewares" 14 | ) 15 | 16 | const getInstanceLogKey = "getInstance" 17 | 18 | func (h APIHandler) GetInstance(w http.ResponseWriter, req *http.Request) { 19 | instanceID := req.PathValue("instance_id") 20 | 21 | logger := h.logger.Session(req.Context(), getInstanceLogKey, blog.InstanceID(instanceID)) 22 | 23 | requestId := fmt.Sprintf("%v", req.Context().Value(middlewares.RequestIdentityKey)) 24 | 25 | version := getAPIVersion(req) 26 | if version.Minor < 14 { 27 | err := errors.New("get instance endpoint only supported starting with OSB version 2.14") 28 | h.respond(w, http.StatusPreconditionFailed, requestId, apiresponses.ErrorResponse{ 29 | Description: err.Error(), 30 | }) 31 | logger.Error(middlewares.ApiVersionInvalidKey, err) 32 | return 33 | } 34 | 35 | details := domain.FetchInstanceDetails{ 36 | ServiceID: req.URL.Query().Get("service_id"), 37 | PlanID: req.URL.Query().Get("plan_id"), 38 | } 39 | 40 | instanceDetails, err := h.serviceBroker.GetInstance(req.Context(), instanceID, details) 41 | if err != nil { 42 | switch err := err.(type) { 43 | case *apiresponses.FailureResponse: 44 | logger.Error(err.LoggerAction(), err) 45 | h.respond(w, err.ValidatedStatusCode(slog.New(logger)), requestId, err.ErrorResponse()) 46 | default: 47 | logger.Error(unknownErrorKey, err) 48 | h.respond(w, http.StatusInternalServerError, requestId, apiresponses.ErrorResponse{ 49 | Description: err.Error(), 50 | }) 51 | } 52 | return 53 | } 54 | 55 | var metadata any 56 | if !instanceDetails.Metadata.IsEmpty() { 57 | metadata = instanceDetails.Metadata 58 | } 59 | 60 | h.respond(w, http.StatusOK, requestId, apiresponses.GetInstanceResponse{ 61 | ServiceID: instanceDetails.ServiceID, 62 | PlanID: instanceDetails.PlanID, 63 | DashboardURL: instanceDetails.DashboardURL, 64 | Parameters: instanceDetails.Parameters, 65 | Metadata: metadata, 66 | }) 67 | } 68 | -------------------------------------------------------------------------------- /handlers/handlers_suite_test.go: -------------------------------------------------------------------------------- 1 | package handlers_test 2 | 3 | import ( 4 | "testing" 5 | 6 | . "github.com/onsi/ginkgo/v2" 7 | . "github.com/onsi/gomega" 8 | ) 9 | 10 | //go:generate go run github.com/maxbrunsfeld/counterfeiter/v6 -generate 11 | //counterfeiter:generate -o fakes/fake_response_writer.go -fake-name FakeResponseWriter net/http.ResponseWriter 12 | 13 | func TestHandlers(t *testing.T) { 14 | RegisterFailHandler(Fail) 15 | RunSpecs(t, "Handlers Suite") 16 | } 17 | -------------------------------------------------------------------------------- /handlers/last_binding_operation.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "log/slog" 7 | "net/http" 8 | 9 | "code.cloudfoundry.org/brokerapi/v13/domain" 10 | "code.cloudfoundry.org/brokerapi/v13/domain/apiresponses" 11 | "code.cloudfoundry.org/brokerapi/v13/internal/blog" 12 | "code.cloudfoundry.org/brokerapi/v13/middlewares" 13 | ) 14 | 15 | const lastBindingOperationLogKey = "lastBindingOperation" 16 | 17 | func (h APIHandler) LastBindingOperation(w http.ResponseWriter, req *http.Request) { 18 | instanceID := req.PathValue("instance_id") 19 | bindingID := req.PathValue("binding_id") 20 | pollDetails := domain.PollDetails{ 21 | PlanID: req.FormValue("plan_id"), 22 | ServiceID: req.FormValue("service_id"), 23 | OperationData: req.FormValue("operation"), 24 | } 25 | 26 | logger := h.logger.Session(req.Context(), lastBindingOperationLogKey, blog.InstanceID(instanceID), blog.BindingID(bindingID)) 27 | 28 | requestId := fmt.Sprintf("%v", req.Context().Value(middlewares.RequestIdentityKey)) 29 | 30 | version := getAPIVersion(req) 31 | if version.Minor < 14 { 32 | err := errors.New("get binding endpoint only supported starting with OSB version 2.14") 33 | h.respond(w, http.StatusPreconditionFailed, requestId, apiresponses.ErrorResponse{ 34 | Description: err.Error(), 35 | }) 36 | logger.Error(middlewares.ApiVersionInvalidKey, err) 37 | return 38 | } 39 | 40 | logger.Info("starting-check-for-binding-operation") 41 | 42 | lastOperation, err := h.serviceBroker.LastBindingOperation(req.Context(), instanceID, bindingID, pollDetails) 43 | if err != nil { 44 | switch err := err.(type) { 45 | case *apiresponses.FailureResponse: 46 | logger.Error(err.LoggerAction(), err) 47 | h.respond(w, err.ValidatedStatusCode(slog.New(logger)), requestId, err.ErrorResponse()) 48 | default: 49 | logger.Error(unknownErrorKey, err) 50 | h.respond(w, http.StatusInternalServerError, requestId, apiresponses.ErrorResponse{ 51 | Description: err.Error(), 52 | }) 53 | } 54 | return 55 | } 56 | 57 | logger.Info("done-check-for-binding-operation", slog.Any("state", lastOperation.State)) 58 | 59 | lastOperationResponse := apiresponses.LastOperationResponse{ 60 | State: lastOperation.State, 61 | Description: lastOperation.Description, 62 | } 63 | h.respond(w, http.StatusOK, requestId, lastOperationResponse) 64 | } 65 | -------------------------------------------------------------------------------- /handlers/last_binding_operation_test.go: -------------------------------------------------------------------------------- 1 | package handlers_test 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "io" 9 | "log/slog" 10 | "net/http" 11 | "net/http/httptest" 12 | 13 | "code.cloudfoundry.org/brokerapi/v13" 14 | "code.cloudfoundry.org/brokerapi/v13/domain" 15 | "code.cloudfoundry.org/brokerapi/v13/domain/apiresponses" 16 | brokerFakes "code.cloudfoundry.org/brokerapi/v13/fakes" 17 | "code.cloudfoundry.org/brokerapi/v13/middlewares" 18 | . "github.com/onsi/ginkgo/v2" 19 | . "github.com/onsi/gomega" 20 | ) 21 | 22 | var _ = Describe("LastBindingOperation", func() { 23 | const ( 24 | instanceID = "some-instance-id" 25 | bindingID = "some-binding-id" 26 | planID = "a-plan" 27 | serviceID = "a-service" 28 | operation = "a-operation" 29 | ) 30 | 31 | var ( 32 | fakeServiceBroker *brokerFakes.AutoFakeServiceBroker 33 | fakeServer *httptest.Server 34 | ) 35 | 36 | BeforeEach(func() { 37 | fakeServiceBroker = new(brokerFakes.AutoFakeServiceBroker) 38 | fakeServer = httptest.NewServer(brokerapi.NewWithOptions(fakeServiceBroker, slog.New(slog.NewJSONHandler(GinkgoWriter, nil)))) 39 | }) 40 | 41 | It("responds with OK when broker can retrieve the last binding operation", func() { 42 | request := newRequest(instanceID, bindingID, planID, serviceID, operation, fakeServer.URL) 43 | expectedLastOperation := domain.LastOperation{ 44 | State: domain.Succeeded, 45 | Description: "muy bien", 46 | } 47 | 48 | fakeServiceBroker.LastBindingOperationReturns(expectedLastOperation, nil) 49 | 50 | response := must(fakeServer.Client().Do(request)) 51 | Expect(response).To(HaveHTTPStatus(http.StatusOK)) 52 | Expect(readBody(response)).To(MatchJSON(toJSON(expectedLastOperation))) 53 | 54 | _, actualInstanceID, actualBindingID, actualPollDetails := fakeServiceBroker.LastBindingOperationArgsForCall(0) 55 | Expect(actualPollDetails).To(Equal(domain.PollDetails{ 56 | PlanID: planID, 57 | ServiceID: serviceID, 58 | OperationData: operation, 59 | })) 60 | Expect(actualInstanceID).To(Equal(instanceID)) 61 | Expect(actualBindingID).To(Equal(bindingID)) 62 | }) 63 | 64 | It("responds with PreConditionFailed when api version is not supported", func() { 65 | request := newRequest(instanceID, bindingID, planID, serviceID, operation, fakeServer.URL) 66 | request.Header.Set("X-Broker-API-Version", "2.13") 67 | 68 | response := must(fakeServer.Client().Do(request)) 69 | Expect(response).To(HaveHTTPStatus(http.StatusPreconditionFailed)) 70 | Expect(readBody(response)).To(MatchJSON(`{"description":"get binding endpoint only supported starting with OSB version 2.14"}`)) 71 | }) 72 | 73 | It("responds with InternalServerError when last binding operation returns unknown error", func() { 74 | request := newRequest(instanceID, bindingID, planID, serviceID, operation, fakeServer.URL) 75 | 76 | fakeServiceBroker.LastBindingOperationReturns(domain.LastOperation{}, errors.New("some error")) 77 | 78 | response := must(fakeServer.Client().Do(request)) 79 | Expect(response).To(HaveHTTPStatus(http.StatusInternalServerError)) 80 | Expect(readBody(response)).To(MatchJSON(`{"description":"some error"}`)) 81 | }) 82 | 83 | It("responds appropriately when last binding operation returns a known error", func() { 84 | request := newRequest(instanceID, bindingID, planID, serviceID, operation, fakeServer.URL) 85 | err := errors.New("some-amazing-error") 86 | fakeServiceBroker.LastBindingOperationReturns( 87 | domain.LastOperation{}, 88 | apiresponses.NewFailureResponse(err, http.StatusTeapot, "last-binding-op"), 89 | ) 90 | 91 | response := must(fakeServer.Client().Do(request)) 92 | Expect(response).To(HaveHTTPStatus(http.StatusTeapot)) 93 | Expect(readBody(response)).To(MatchJSON(`{"description":"some-amazing-error"}`)) 94 | }) 95 | }) 96 | 97 | func toJSON(operation domain.LastOperation) []byte { 98 | d, err := json.Marshal(operation) 99 | Expect(err).ToNot(HaveOccurred()) 100 | return d 101 | } 102 | 103 | func newRequest(instanceID, bindingID, planID, serviceID, operation, serverURL string) *http.Request { 104 | request, err := http.NewRequest( 105 | "GET", 106 | fmt.Sprintf("%s/v2/service_instances/%s/service_bindings/%s/last_operation", serverURL, instanceID, bindingID), 107 | nil, 108 | ) 109 | Expect(err).ToNot(HaveOccurred()) 110 | request.Header.Add("X-Broker-API-Version", "2.14") 111 | 112 | q := request.URL.Query() 113 | q.Add("plan_id", planID) 114 | q.Add("service_id", serviceID) 115 | q.Add("operation", operation) 116 | request.URL.RawQuery = q.Encode() 117 | 118 | ctx := request.Context() 119 | ctx = context.WithValue(ctx, middlewares.CorrelationIDKey, "fake-correlation-id") 120 | 121 | return request.WithContext(ctx) 122 | } 123 | 124 | func readBody(res *http.Response) string { 125 | GinkgoHelper() 126 | 127 | body := must(io.ReadAll(res.Body)) 128 | res.Body.Close() 129 | return string(body) 130 | } 131 | 132 | func must[A any](input A, err error) A { 133 | GinkgoHelper() 134 | 135 | Expect(err).ToNot(HaveOccurred()) 136 | return input 137 | } 138 | -------------------------------------------------------------------------------- /handlers/last_operation.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "fmt" 5 | "log/slog" 6 | "net/http" 7 | 8 | "code.cloudfoundry.org/brokerapi/v13/domain" 9 | "code.cloudfoundry.org/brokerapi/v13/domain/apiresponses" 10 | "code.cloudfoundry.org/brokerapi/v13/internal/blog" 11 | "code.cloudfoundry.org/brokerapi/v13/middlewares" 12 | ) 13 | 14 | const lastOperationLogKey = "lastOperation" 15 | 16 | func (h APIHandler) LastOperation(w http.ResponseWriter, req *http.Request) { 17 | instanceID := req.PathValue("instance_id") 18 | pollDetails := domain.PollDetails{ 19 | PlanID: req.FormValue("plan_id"), 20 | ServiceID: req.FormValue("service_id"), 21 | OperationData: req.FormValue("operation"), 22 | } 23 | 24 | logger := h.logger.Session(req.Context(), lastOperationLogKey, blog.InstanceID(instanceID)) 25 | 26 | logger.Info("starting-check-for-operation") 27 | 28 | requestId := fmt.Sprintf("%v", req.Context().Value(middlewares.RequestIdentityKey)) 29 | 30 | lastOperation, err := h.serviceBroker.LastOperation(req.Context(), instanceID, pollDetails) 31 | if err != nil { 32 | switch err := err.(type) { 33 | case *apiresponses.FailureResponse: 34 | logger.Error(err.LoggerAction(), err) 35 | h.respond(w, err.ValidatedStatusCode(slog.New(logger)), requestId, err.ErrorResponse()) 36 | default: 37 | logger.Error(unknownErrorKey, err) 38 | h.respond(w, http.StatusInternalServerError, requestId, apiresponses.ErrorResponse{ 39 | Description: err.Error(), 40 | }) 41 | } 42 | return 43 | } 44 | 45 | logger.Info("done-check-for-operation", slog.Any("state", lastOperation.State)) 46 | 47 | lastOperationResponse := apiresponses.LastOperationResponse{ 48 | State: lastOperation.State, 49 | Description: lastOperation.Description, 50 | } 51 | 52 | h.respond(w, http.StatusOK, requestId, lastOperationResponse) 53 | } 54 | -------------------------------------------------------------------------------- /handlers/provision.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "log/slog" 7 | "net/http" 8 | 9 | "code.cloudfoundry.org/brokerapi/v13/domain" 10 | "code.cloudfoundry.org/brokerapi/v13/domain/apiresponses" 11 | "code.cloudfoundry.org/brokerapi/v13/internal/blog" 12 | "code.cloudfoundry.org/brokerapi/v13/middlewares" 13 | "code.cloudfoundry.org/brokerapi/v13/utils" 14 | ) 15 | 16 | const ( 17 | provisionLogKey = "provision" 18 | instanceDetailsLogKey = "instance-details" 19 | invalidServiceID = "invalid-service-id" 20 | invalidPlanID = "invalid-plan-id" 21 | ) 22 | 23 | func (h APIHandler) Provision(w http.ResponseWriter, req *http.Request) { 24 | instanceID := req.PathValue("instance_id") 25 | 26 | logger := h.logger.Session(req.Context(), provisionLogKey, blog.InstanceID(instanceID)) 27 | 28 | requestId := fmt.Sprintf("%v", req.Context().Value(middlewares.RequestIdentityKey)) 29 | 30 | var details domain.ProvisionDetails 31 | if err := json.NewDecoder(req.Body).Decode(&details); err != nil { 32 | logger.Error(invalidServiceDetailsErrorKey, err) 33 | h.respond(w, http.StatusUnprocessableEntity, requestId, apiresponses.ErrorResponse{ 34 | Description: err.Error(), 35 | }) 36 | return 37 | } 38 | 39 | if details.ServiceID == "" { 40 | logger.Error(serviceIdMissingKey, serviceIdError) 41 | h.respond(w, http.StatusBadRequest, requestId, apiresponses.ErrorResponse{ 42 | Description: serviceIdError.Error(), 43 | }) 44 | return 45 | } 46 | 47 | if details.PlanID == "" { 48 | logger.Error(planIdMissingKey, planIdError) 49 | h.respond(w, http.StatusBadRequest, requestId, apiresponses.ErrorResponse{ 50 | Description: planIdError.Error(), 51 | }) 52 | return 53 | } 54 | 55 | valid := false 56 | services, _ := h.serviceBroker.Services(req.Context()) 57 | for _, service := range services { 58 | if service.ID == details.ServiceID { 59 | req = req.WithContext(utils.AddServiceToContext(req.Context(), &service)) 60 | valid = true 61 | break 62 | } 63 | } 64 | if !valid { 65 | logger.Error(invalidServiceID, invalidServiceIDError) 66 | h.respond(w, http.StatusBadRequest, requestId, apiresponses.ErrorResponse{ 67 | Description: invalidServiceIDError.Error(), 68 | }) 69 | return 70 | } 71 | 72 | valid = false 73 | for _, service := range services { 74 | for _, plan := range service.Plans { 75 | if plan.ID == details.PlanID { 76 | req = req.WithContext(utils.AddServicePlanToContext(req.Context(), &plan)) 77 | valid = true 78 | break 79 | } 80 | } 81 | } 82 | if !valid { 83 | logger.Error(invalidPlanID, invalidPlanIDError) 84 | h.respond(w, http.StatusBadRequest, requestId, apiresponses.ErrorResponse{ 85 | Description: invalidPlanIDError.Error(), 86 | }) 87 | return 88 | } 89 | 90 | asyncAllowed := req.FormValue("accepts_incomplete") == "true" 91 | 92 | logger = logger.With(slog.Any(instanceDetailsLogKey, details)) 93 | 94 | provisionResponse, err := h.serviceBroker.Provision(req.Context(), instanceID, details, asyncAllowed) 95 | 96 | if err != nil { 97 | switch err := err.(type) { 98 | case *apiresponses.FailureResponse: 99 | logger.Error(err.LoggerAction(), err) 100 | h.respond(w, err.ValidatedStatusCode(slog.New(logger)), requestId, err.ErrorResponse()) 101 | default: 102 | logger.Error(unknownErrorKey, err) 103 | h.respond(w, http.StatusInternalServerError, requestId, apiresponses.ErrorResponse{ 104 | Description: err.Error(), 105 | }) 106 | } 107 | return 108 | } 109 | 110 | var metadata any 111 | if !provisionResponse.Metadata.IsEmpty() { 112 | metadata = provisionResponse.Metadata 113 | } 114 | 115 | if provisionResponse.AlreadyExists { 116 | h.respond(w, http.StatusOK, requestId, apiresponses.ProvisioningResponse{ 117 | DashboardURL: provisionResponse.DashboardURL, 118 | Metadata: metadata, 119 | }) 120 | } else if provisionResponse.IsAsync { 121 | h.respond(w, http.StatusAccepted, requestId, apiresponses.ProvisioningResponse{ 122 | DashboardURL: provisionResponse.DashboardURL, 123 | OperationData: provisionResponse.OperationData, 124 | Metadata: metadata, 125 | }) 126 | } else { 127 | h.respond(w, http.StatusCreated, requestId, apiresponses.ProvisioningResponse{ 128 | DashboardURL: provisionResponse.DashboardURL, 129 | Metadata: metadata, 130 | }) 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /handlers/unbind.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "fmt" 5 | "log/slog" 6 | "net/http" 7 | 8 | "code.cloudfoundry.org/brokerapi/v13/domain" 9 | "code.cloudfoundry.org/brokerapi/v13/domain/apiresponses" 10 | "code.cloudfoundry.org/brokerapi/v13/internal/blog" 11 | "code.cloudfoundry.org/brokerapi/v13/middlewares" 12 | ) 13 | 14 | const unbindLogKey = "unbind" 15 | 16 | func (h APIHandler) Unbind(w http.ResponseWriter, req *http.Request) { 17 | instanceID := req.PathValue("instance_id") 18 | bindingID := req.PathValue("binding_id") 19 | 20 | logger := h.logger.Session(req.Context(), unbindLogKey, blog.InstanceID(instanceID), blog.BindingID(bindingID)) 21 | 22 | requestId := fmt.Sprintf("%v", req.Context().Value(middlewares.RequestIdentityKey)) 23 | 24 | details := domain.UnbindDetails{ 25 | PlanID: req.FormValue("plan_id"), 26 | ServiceID: req.FormValue("service_id"), 27 | } 28 | 29 | if details.ServiceID == "" { 30 | h.respond(w, http.StatusBadRequest, requestId, apiresponses.ErrorResponse{ 31 | Description: serviceIdError.Error(), 32 | }) 33 | logger.Error(serviceIdMissingKey, serviceIdError) 34 | return 35 | } 36 | 37 | if details.PlanID == "" { 38 | h.respond(w, http.StatusBadRequest, requestId, apiresponses.ErrorResponse{ 39 | Description: planIdError.Error(), 40 | }) 41 | logger.Error(planIdMissingKey, planIdError) 42 | return 43 | } 44 | 45 | asyncAllowed := req.FormValue("accepts_incomplete") == "true" 46 | unbindResponse, err := h.serviceBroker.Unbind(req.Context(), instanceID, bindingID, details, asyncAllowed) 47 | if err != nil { 48 | switch err := err.(type) { 49 | case *apiresponses.FailureResponse: 50 | logger.Error(err.LoggerAction(), err) 51 | h.respond(w, err.ValidatedStatusCode(slog.New(logger)), requestId, err.ErrorResponse()) 52 | default: 53 | logger.Error(unknownErrorKey, err) 54 | h.respond(w, http.StatusInternalServerError, requestId, apiresponses.ErrorResponse{ 55 | Description: err.Error(), 56 | }) 57 | } 58 | return 59 | } 60 | 61 | if unbindResponse.IsAsync { 62 | h.respond(w, http.StatusAccepted, requestId, apiresponses.UnbindResponse{ 63 | OperationData: unbindResponse.OperationData, 64 | }) 65 | } else { 66 | h.respond(w, http.StatusOK, requestId, apiresponses.EmptyResponse{}) 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /handlers/update.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "log/slog" 7 | "net/http" 8 | "strconv" 9 | 10 | "code.cloudfoundry.org/brokerapi/v13/domain" 11 | "code.cloudfoundry.org/brokerapi/v13/domain/apiresponses" 12 | "code.cloudfoundry.org/brokerapi/v13/internal/blog" 13 | "code.cloudfoundry.org/brokerapi/v13/middlewares" 14 | ) 15 | 16 | const updateLogKey = "update" 17 | 18 | func (h APIHandler) Update(w http.ResponseWriter, req *http.Request) { 19 | instanceID := req.PathValue("instance_id") 20 | 21 | logger := h.logger.Session(req.Context(), updateLogKey, blog.InstanceID(instanceID)) 22 | 23 | requestId := fmt.Sprintf("%v", req.Context().Value(middlewares.RequestIdentityKey)) 24 | 25 | var details domain.UpdateDetails 26 | if err := json.NewDecoder(req.Body).Decode(&details); err != nil { 27 | logger.Error(invalidServiceDetailsErrorKey, err) 28 | h.respond(w, http.StatusUnprocessableEntity, requestId, apiresponses.ErrorResponse{ 29 | Description: err.Error(), 30 | }) 31 | return 32 | } 33 | 34 | if details.ServiceID == "" { 35 | logger.Error(serviceIdMissingKey, serviceIdError) 36 | h.respond(w, http.StatusBadRequest, requestId, apiresponses.ErrorResponse{ 37 | Description: serviceIdError.Error(), 38 | }) 39 | return 40 | } 41 | 42 | acceptsIncompleteFlag, _ := strconv.ParseBool(req.URL.Query().Get("accepts_incomplete")) 43 | 44 | updateServiceSpec, err := h.serviceBroker.Update(req.Context(), instanceID, details, acceptsIncompleteFlag) 45 | if err != nil { 46 | switch err := err.(type) { 47 | case *apiresponses.FailureResponse: 48 | logger.Error(err.LoggerAction(), err) 49 | h.respond(w, err.ValidatedStatusCode(slog.New(logger)), requestId, err.ErrorResponse()) 50 | default: 51 | logger.Error(unknownErrorKey, err) 52 | h.respond(w, http.StatusInternalServerError, requestId, apiresponses.ErrorResponse{ 53 | Description: err.Error(), 54 | }) 55 | } 56 | return 57 | } 58 | 59 | var metadata any 60 | if !updateServiceSpec.Metadata.IsEmpty() { 61 | metadata = updateServiceSpec.Metadata 62 | } 63 | 64 | statusCode := http.StatusOK 65 | if updateServiceSpec.IsAsync { 66 | statusCode = http.StatusAccepted 67 | } 68 | h.respond(w, statusCode, requestId, apiresponses.UpdateResponse{ 69 | OperationData: updateServiceSpec.OperationData, 70 | DashboardURL: updateServiceSpec.DashboardURL, 71 | Metadata: metadata, 72 | }) 73 | } 74 | -------------------------------------------------------------------------------- /internal/blog/blog.go: -------------------------------------------------------------------------------- 1 | // Package blog is the brokerapi logger 2 | // BrokerAPI was originally written to use the CloudFoundry Lager logger (https://github.com/cloudfoundry/lager), 3 | // and it relied on some idiosyncrasies of that logger that are not found in the (subsequently written) 4 | // Go standard library log/slog logger. This package is a wrapper around log/slog that adds back the 5 | // idiosyncrasies of lager, minimizes boilerplate code, and keeps the behavior as similar as possible. 6 | // It also implements the slog.Handler interface so that it can easily be converted into a slog.Logger. 7 | // This is useful when calling public APIs (such as FailureResponse.ValidatedStatusCode) which take a 8 | // slog.Logger as an input, and because they are public cannot take a Blog as an input. 9 | package blog 10 | 11 | import ( 12 | "context" 13 | "log/slog" 14 | "strings" 15 | 16 | "code.cloudfoundry.org/brokerapi/v13/middlewares" 17 | ) 18 | 19 | const ( 20 | instanceIDLogKey = "instance-id" 21 | bindingIDLogKey = "binding-id" 22 | errorKey = "error" 23 | ) 24 | 25 | type Blog struct { 26 | logger *slog.Logger 27 | prefix string 28 | } 29 | 30 | func New(logger *slog.Logger) Blog { 31 | return Blog{logger: logger} 32 | } 33 | 34 | // Session emulates a Lager logger session. It returns a new logger that will always log the 35 | // attributes, prefix, and data from the context. 36 | func (b Blog) Session(ctx context.Context, prefix string, attr ...any) Blog { 37 | for _, key := range []middlewares.ContextKey{middlewares.CorrelationIDKey, middlewares.RequestIdentityKey} { 38 | if value := ctx.Value(key); value != nil { 39 | attr = append(attr, slog.Any(string(key), value)) 40 | } 41 | } 42 | 43 | return Blog{ 44 | logger: b.logger.With(attr...), 45 | prefix: appendPrefix(b.prefix, prefix), 46 | } 47 | } 48 | 49 | // Error logs an error. It takes an error type as a convenience, which is different to slog.Logger.Error() 50 | func (b Blog) Error(message string, err error, attr ...any) { 51 | b.logger.Error(join(b.prefix, message), append([]any{slog.Any(errorKey, err)}, attr...)...) 52 | } 53 | 54 | // Info logs information. It behaves a lot file slog.Logger.Info() 55 | func (b Blog) Info(message string, attr ...any) { 56 | b.logger.Info(join(b.prefix, message), attr...) 57 | } 58 | 59 | // With returns a logger that always logs the specified attributes 60 | func (b Blog) With(attr ...any) Blog { 61 | b.logger = b.logger.With(attr...) 62 | return b 63 | } 64 | 65 | // Enabled is required implement the slog.Handler interface 66 | func (b Blog) Enabled(context.Context, slog.Level) bool { 67 | return true 68 | } 69 | 70 | // WithAttrs is required implement the slog.Handler interface 71 | func (b Blog) WithAttrs(attrs []slog.Attr) slog.Handler { 72 | var attributes []any 73 | for _, a := range attrs { 74 | attributes = append(attributes, a) 75 | } 76 | return b.With(attributes...) 77 | } 78 | 79 | // WithGroup is required implement the slog.Handler interface 80 | func (b Blog) WithGroup(string) slog.Handler { 81 | return b 82 | } 83 | 84 | // Handle is required implement the slog.Handler interface 85 | func (b Blog) Handle(_ context.Context, record slog.Record) error { 86 | msg := join(b.prefix, record.Message) 87 | switch record.Level { 88 | case slog.LevelDebug: 89 | b.logger.Debug(msg) 90 | case slog.LevelInfo: 91 | b.logger.Info(msg) 92 | case slog.LevelWarn: 93 | b.logger.Warn(msg) 94 | default: 95 | b.logger.Error(msg) 96 | } 97 | 98 | return nil 99 | } 100 | 101 | // InstanceID creates an attribute from an instance ID 102 | func InstanceID(instanceID string) slog.Attr { 103 | return slog.String(instanceIDLogKey, instanceID) 104 | } 105 | 106 | // BindingID creates an attribute from an binding ID 107 | func BindingID(bindingID string) slog.Attr { 108 | return slog.String(bindingIDLogKey, bindingID) 109 | } 110 | 111 | func join(s ...string) string { 112 | return strings.Join(s, ".") 113 | } 114 | 115 | func appendPrefix(existing, addition string) string { 116 | switch existing { 117 | case "": 118 | return addition 119 | default: 120 | return join(existing, addition) 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /internal/blog/blog_suite_test.go: -------------------------------------------------------------------------------- 1 | package blog_test 2 | 3 | import ( 4 | "testing" 5 | 6 | . "github.com/onsi/ginkgo/v2" 7 | . "github.com/onsi/gomega" 8 | ) 9 | 10 | func TestBlog(t *testing.T) { 11 | RegisterFailHandler(Fail) 12 | RunSpecs(t, "BrokerAPI logger Suite") 13 | } 14 | -------------------------------------------------------------------------------- /internal/blog/blog_test.go: -------------------------------------------------------------------------------- 1 | package blog_test 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "log/slog" 7 | 8 | "code.cloudfoundry.org/brokerapi/v13/internal/blog" 9 | "code.cloudfoundry.org/brokerapi/v13/middlewares" 10 | . "github.com/onsi/ginkgo/v2" 11 | . "github.com/onsi/gomega" 12 | "github.com/onsi/gomega/gbytes" 13 | ) 14 | 15 | var _ = Describe("Context data", func() { 16 | When("the context has the values", func() { 17 | It("logs the values", func() { 18 | const ( 19 | correlationID = "fake-correlation-id" 20 | requestID = "fake-request-id" 21 | ) 22 | 23 | ctx := context.TODO() 24 | ctx = context.WithValue(ctx, middlewares.CorrelationIDKey, correlationID) 25 | ctx = context.WithValue(ctx, middlewares.RequestIdentityKey, requestID) 26 | 27 | buffer := gbytes.NewBuffer() 28 | logger := slog.New(slog.NewJSONHandler(buffer, nil)) 29 | 30 | blog.New(logger).Session(ctx, "prefix").Info("hello") 31 | 32 | var receiver map[string]any 33 | Expect(json.Unmarshal(buffer.Contents(), &receiver)).To(Succeed()) 34 | 35 | Expect(receiver).To(HaveKeyWithValue(string(middlewares.CorrelationIDKey), correlationID)) 36 | Expect(receiver).To(HaveKeyWithValue(string(middlewares.RequestIdentityKey), requestID)) 37 | }) 38 | }) 39 | 40 | When("the context does not have the values", func() { 41 | It("does not log them", func() { 42 | buffer := gbytes.NewBuffer() 43 | logger := slog.New(slog.NewJSONHandler(buffer, nil)) 44 | 45 | blog.New(logger).Session(context.TODO(), "prefix").Info("hello") 46 | 47 | var receiver map[string]any 48 | Expect(json.Unmarshal(buffer.Contents(), &receiver)).To(Succeed()) 49 | 50 | Expect(receiver).NotTo(HaveKey(string(middlewares.CorrelationIDKey))) 51 | Expect(receiver).NotTo(HaveKey(string(middlewares.RequestIdentityKey))) 52 | }) 53 | }) 54 | }) 55 | -------------------------------------------------------------------------------- /internal/middleware/middleware.go: -------------------------------------------------------------------------------- 1 | // Package middleware implements a middleware combiner. For implementations of middleware, see 2 | // the middlewares package 3 | package middleware 4 | 5 | import ( 6 | "net/http" 7 | "slices" 8 | ) 9 | 10 | // Use will combine multiple middleware functions and apply them to the specified endpoint. Middleware is defined as: 11 | // 12 | // func(next http.Handler) http.Handler 13 | // 14 | // Middleware is called (by calling ServeHTTP() on it, or as a http.HandlerFunc) and it should either: 15 | // - respond via the http.ResponseWriter, typically with an error 16 | // - call the next middleware 17 | // Optionally it may also do something useful, such as modifying the context. 18 | // Eventually, once all the middleware has been called, the endpoint will be called 19 | func Use(endpoint http.Handler, middlewares ...func(next http.Handler) http.Handler) http.Handler { 20 | middlewares = excludeNils(middlewares) 21 | 22 | // If there's no middleware then just return the bare endpoint 23 | if len(middlewares) == 0 { 24 | return endpoint 25 | } 26 | 27 | // We wrap middleware in reverse order so that they are applied in the correct order. 28 | // Consider the analogy of a wrapped present. The first wrapping that you see (the outside) 29 | // needs to be applied last; and last wrapping that you see needs to be applied first. 30 | slices.Reverse(middlewares) 31 | for _, m := range middlewares { 32 | endpoint = m(endpoint) 33 | } 34 | 35 | return endpoint 36 | } 37 | 38 | func excludeNils(input []func(http.Handler) http.Handler) []func(http.Handler) http.Handler { 39 | output := make([]func(http.Handler) http.Handler, 0, len(input)) 40 | for _, m := range input { 41 | if m != nil { 42 | output = append(output, m) 43 | } 44 | } 45 | return output 46 | } 47 | -------------------------------------------------------------------------------- /internal/middleware/middleware_suite_test.go: -------------------------------------------------------------------------------- 1 | package middleware_test 2 | 3 | import ( 4 | "testing" 5 | 6 | . "github.com/onsi/ginkgo/v2" 7 | . "github.com/onsi/gomega" 8 | ) 9 | 10 | func TestMiddleware(t *testing.T) { 11 | RegisterFailHandler(Fail) 12 | RunSpecs(t, "Middleware Suite") 13 | } 14 | -------------------------------------------------------------------------------- /internal/middleware/middleware_test.go: -------------------------------------------------------------------------------- 1 | package middleware_test 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | 7 | "code.cloudfoundry.org/brokerapi/v13/internal/middleware" 8 | . "github.com/onsi/ginkgo/v2" 9 | . "github.com/onsi/gomega" 10 | ) 11 | 12 | var _ = Describe("Use()", func() { 13 | It("can handle an empty list", func() { 14 | endpointCalled := false 15 | endpoint := http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { 16 | endpointCalled = true 17 | }) 18 | 19 | middleware.Use(endpoint).ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("GET", "/", nil)) 20 | 21 | Expect(endpointCalled).To(BeTrue()) 22 | }) 23 | 24 | It("calls middleware in the right order", func() { 25 | var order []string 26 | first := func(next http.Handler) http.Handler { 27 | return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { 28 | order = append(order, "first") 29 | next.ServeHTTP(w, req) 30 | }) 31 | } 32 | 33 | second := func(next http.Handler) http.Handler { 34 | return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { 35 | order = append(order, "second") 36 | next.ServeHTTP(w, req) 37 | }) 38 | } 39 | 40 | third := func(next http.Handler) http.Handler { 41 | return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { 42 | order = append(order, "third") 43 | next.ServeHTTP(w, req) 44 | }) 45 | } 46 | 47 | endpointCalled := false 48 | endpoint := http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { 49 | endpointCalled = true 50 | }) 51 | middleware.Use(endpoint, first, second, third).ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("GET", "/", nil)) 52 | 53 | Expect(order).To(Equal([]string{"first", "second", "third"})) 54 | Expect(endpointCalled).To(BeTrue()) 55 | }) 56 | 57 | It("does not call the endpoint when the middleware rejects the request", func() { 58 | rejector := func(next http.Handler) http.Handler { 59 | return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { 60 | http.Error(w, http.StatusText(http.StatusTeapot), http.StatusTeapot) 61 | }) 62 | } 63 | 64 | endpointCalled := false 65 | endpoint := http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { 66 | endpointCalled = true 67 | }) 68 | middleware.Use(endpoint, rejector).ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("GET", "/", nil)) 69 | 70 | Expect(endpointCalled).To(BeFalse()) 71 | }) 72 | }) 73 | -------------------------------------------------------------------------------- /maintenance_info.go: -------------------------------------------------------------------------------- 1 | package brokerapi 2 | 3 | import ( 4 | "code.cloudfoundry.org/brokerapi/v13/domain" 5 | ) 6 | 7 | // Deprecated: Use code.cloudfoundry.org/brokerapi/v13/domain 8 | type MaintenanceInfo = domain.MaintenanceInfo 9 | -------------------------------------------------------------------------------- /maintenance_info_test.go: -------------------------------------------------------------------------------- 1 | package brokerapi_test 2 | 3 | import ( 4 | "code.cloudfoundry.org/brokerapi/v13" 5 | . "github.com/onsi/ginkgo/v2" 6 | . "github.com/onsi/gomega" 7 | ) 8 | 9 | var _ = Describe("MaintenanceInfo", func() { 10 | Describe("Equals", func() { 11 | DescribeTable( 12 | "returns false", 13 | func(m1, m2 brokerapi.MaintenanceInfo) { 14 | Expect(m1.Equals(m2)).To(BeFalse()) 15 | }, 16 | Entry( 17 | "one property is missing", 18 | brokerapi.MaintenanceInfo{ 19 | Public: map[string]string{"foo": "bar"}, 20 | Private: "test", 21 | Version: "1.2.3", 22 | }, 23 | brokerapi.MaintenanceInfo{ 24 | Public: map[string]string{"foo": "bar"}, 25 | Private: "test", 26 | }), 27 | Entry( 28 | "one extra property is added", 29 | brokerapi.MaintenanceInfo{ 30 | Public: map[string]string{"foo": "bar"}, 31 | Private: "test", 32 | }, 33 | brokerapi.MaintenanceInfo{ 34 | Public: map[string]string{"foo": "bar"}, 35 | Private: "test", 36 | Version: "1.2.3", 37 | }), 38 | Entry( 39 | "one property is different", 40 | brokerapi.MaintenanceInfo{ 41 | Public: map[string]string{"foo": "bar"}, 42 | Private: "test", 43 | Version: "1.2.3", 44 | }, 45 | brokerapi.MaintenanceInfo{ 46 | Public: map[string]string{"foo": "bar"}, 47 | Private: "test-not-the-same", 48 | Version: "1.2.3", 49 | }), 50 | Entry( 51 | "all properties are missing in one of the objects", 52 | brokerapi.MaintenanceInfo{ 53 | Public: map[string]string{"foo": "bar"}, 54 | Private: "test", 55 | Version: "1.2.3", 56 | }, 57 | brokerapi.MaintenanceInfo{}), 58 | Entry( 59 | "all properties are defined but different", 60 | brokerapi.MaintenanceInfo{ 61 | Public: map[string]string{"foo": "bar"}, 62 | Private: "test", 63 | Version: "1.2.3", 64 | }, 65 | brokerapi.MaintenanceInfo{ 66 | Public: map[string]string{"bar": "foo"}, 67 | Private: "test-not-the-same", 68 | Version: "8.9.6-rc3", 69 | }), 70 | ) 71 | 72 | DescribeTable( 73 | "returns true", 74 | func(m1, m2 brokerapi.MaintenanceInfo) { 75 | Expect(m1.Equals(m2)).To(BeTrue()) 76 | }, 77 | Entry( 78 | "all properties are the same", 79 | brokerapi.MaintenanceInfo{ 80 | Public: map[string]string{"foo": "bar"}, 81 | Private: "test", 82 | Version: "1.2.3", 83 | }, 84 | brokerapi.MaintenanceInfo{ 85 | Public: map[string]string{"foo": "bar"}, 86 | Private: "test", 87 | Version: "1.2.3", 88 | }), 89 | Entry( 90 | "all properties are empty", 91 | brokerapi.MaintenanceInfo{}, 92 | brokerapi.MaintenanceInfo{}), 93 | Entry( 94 | "both struct's are nil", 95 | nil, 96 | nil), 97 | ) 98 | }) 99 | }) 100 | -------------------------------------------------------------------------------- /middlewares/api_version_header.go: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2015-Present Pivotal Software, Inc. All rights reserved. 2 | 3 | // This program and the accompanying materials are made available under 4 | // the terms of the under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | 16 | package middlewares 17 | 18 | import ( 19 | "encoding/json" 20 | "errors" 21 | "fmt" 22 | "log/slog" 23 | "net/http" 24 | ) 25 | 26 | const ( 27 | ApiVersionInvalidKey = "broker-api-version-invalid" 28 | 29 | apiVersionLogKey = "version-header-check" 30 | ) 31 | 32 | type APIVersionMiddleware struct { 33 | Logger *slog.Logger 34 | } 35 | 36 | type ErrorResponse struct { 37 | Description string 38 | } 39 | 40 | func (m APIVersionMiddleware) ValidateAPIVersionHdr(next http.Handler) http.Handler { 41 | return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { 42 | err := checkBrokerAPIVersionHdr(req) 43 | if err != nil { 44 | m.Logger.Error(fmt.Sprintf("%s.%s", apiVersionLogKey, ApiVersionInvalidKey), slog.Any("error", err)) 45 | 46 | w.Header().Set("Content-type", "application/json") 47 | setBrokerRequestIdentityHeader(req, w) 48 | 49 | statusResponse := http.StatusPreconditionFailed 50 | w.WriteHeader(statusResponse) 51 | errorResp := ErrorResponse{ 52 | Description: err.Error(), 53 | } 54 | if err := json.NewEncoder(w).Encode(errorResp); err != nil { 55 | m.Logger.Error(fmt.Sprintf("%s.%s", apiVersionLogKey, "encoding response"), slog.Any("error", err), slog.Int("status", statusResponse), slog.Any("response", errorResp)) 56 | } 57 | 58 | return 59 | } 60 | 61 | next.ServeHTTP(w, req) 62 | }) 63 | } 64 | 65 | func setBrokerRequestIdentityHeader(req *http.Request, w http.ResponseWriter) { 66 | requestID := req.Header.Get("X-Broker-API-Request-Identity") 67 | if requestID != "" { 68 | w.Header().Set("X-Broker-API-Request-Identity", requestID) 69 | } 70 | } 71 | 72 | func checkBrokerAPIVersionHdr(req *http.Request) error { 73 | var version struct { 74 | Major int 75 | Minor int 76 | } 77 | apiVersion := req.Header.Get("X-Broker-API-Version") 78 | if apiVersion == "" { 79 | return errors.New("X-Broker-API-Version Header not set") 80 | } 81 | if n, err := fmt.Sscanf(apiVersion, "%d.%d", &version.Major, &version.Minor); err != nil || n < 2 { 82 | return errors.New("X-Broker-API-Version Header must contain a version") 83 | } 84 | 85 | if version.Major != 2 { 86 | return errors.New("X-Broker-API-Version Header must be 2.x") 87 | } 88 | return nil 89 | } 90 | -------------------------------------------------------------------------------- /middlewares/context_keys.go: -------------------------------------------------------------------------------- 1 | package middlewares 2 | 3 | type ContextKey string 4 | 5 | const ( 6 | CorrelationIDKey ContextKey = "correlation-id" 7 | InfoLocationKey ContextKey = "infoLocation" 8 | OriginatingIdentityKey ContextKey = "originatingIdentity" 9 | RequestIdentityKey ContextKey = "requestIdentity" 10 | ) 11 | -------------------------------------------------------------------------------- /middlewares/correlation_id_header.go: -------------------------------------------------------------------------------- 1 | package middlewares 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | 7 | "github.com/google/uuid" 8 | ) 9 | 10 | var correlationIDHeaders = []string{"X-Correlation-ID", "X-CorrelationID", "X-ForRequest-ID", "X-Request-ID", "X-Vcap-Request-Id"} 11 | 12 | func AddCorrelationIDToContext(next http.Handler) http.Handler { 13 | return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { 14 | var correlationID string 15 | var found bool 16 | 17 | for _, header := range correlationIDHeaders { 18 | headerValue := req.Header.Get(header) 19 | if headerValue != "" { 20 | correlationID = headerValue 21 | found = true 22 | break 23 | } 24 | } 25 | 26 | if !found { 27 | correlationID = uuid.NewString() 28 | } 29 | 30 | newCtx := context.WithValue(req.Context(), CorrelationIDKey, correlationID) 31 | next.ServeHTTP(w, req.WithContext(newCtx)) 32 | }) 33 | } 34 | -------------------------------------------------------------------------------- /middlewares/info_location_header.go: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2015-Present Pivotal Software, Inc. All rights reserved. 2 | 3 | // This program and the accompanying materials are made available under 4 | // the terms of the under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | 16 | package middlewares 17 | 18 | import ( 19 | "context" 20 | "net/http" 21 | ) 22 | 23 | func AddInfoLocationToContext(next http.Handler) http.Handler { 24 | return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { 25 | infoLocation := req.Header.Get("X-Api-Info-Location") 26 | newCtx := context.WithValue(req.Context(), InfoLocationKey, infoLocation) 27 | next.ServeHTTP(w, req.WithContext(newCtx)) 28 | }) 29 | } 30 | -------------------------------------------------------------------------------- /middlewares/originating_identity_header.go: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2015-Present Pivotal Software, Inc. All rights reserved. 2 | 3 | // This program and the accompanying materials are made available under 4 | // the terms of the under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | 16 | package middlewares 17 | 18 | import ( 19 | "context" 20 | "net/http" 21 | ) 22 | 23 | func AddOriginatingIdentityToContext(next http.Handler) http.Handler { 24 | return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { 25 | originatingIdentity := req.Header.Get("X-Broker-API-Originating-Identity") 26 | newCtx := context.WithValue(req.Context(), OriginatingIdentityKey, originatingIdentity) 27 | next.ServeHTTP(w, req.WithContext(newCtx)) 28 | }) 29 | } 30 | -------------------------------------------------------------------------------- /middlewares/request_identity_header.go: -------------------------------------------------------------------------------- 1 | package middlewares 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | ) 7 | 8 | func AddRequestIdentityToContext(next http.Handler) http.Handler { 9 | return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { 10 | requestIdentity := req.Header.Get("X-Broker-API-Request-Identity") 11 | newCtx := context.WithValue(req.Context(), RequestIdentityKey, requestIdentity) 12 | next.ServeHTTP(w, req.WithContext(newCtx)) 13 | }) 14 | } 15 | -------------------------------------------------------------------------------- /response.go: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2015-Present Pivotal Software, Inc. All rights reserved. 2 | 3 | // This program and the accompanying materials are made available under 4 | // the terms of the under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | 16 | package brokerapi 17 | 18 | import ( 19 | "code.cloudfoundry.org/brokerapi/v13/domain" 20 | "code.cloudfoundry.org/brokerapi/v13/domain/apiresponses" 21 | ) 22 | 23 | // Deprecated: Use code.cloudfoundry.org/brokerapi/v13/domain/apiresponses 24 | type EmptyResponse = apiresponses.EmptyResponse 25 | 26 | // Deprecated: Use code.cloudfoundry.org/brokerapi/v13/domain/apiresponses 27 | type ErrorResponse = apiresponses.ErrorResponse 28 | 29 | // Deprecated: Use code.cloudfoundry.org/brokerapi/v13/domain/apiresponses 30 | type CatalogResponse = apiresponses.CatalogResponse 31 | 32 | // Deprecated: Use code.cloudfoundry.org/brokerapi/v13/domain/apiresponses 33 | type ProvisioningResponse = apiresponses.ProvisioningResponse 34 | 35 | // Deprecated: Use code.cloudfoundry.org/brokerapi/v13/domain/apiresponses 36 | type GetInstanceResponse = apiresponses.GetInstanceResponse 37 | 38 | // Deprecated: Use code.cloudfoundry.org/brokerapi/v13/domain/apiresponses 39 | type UpdateResponse = apiresponses.UpdateResponse 40 | 41 | // Deprecated: Use code.cloudfoundry.org/brokerapi/v13/domain/apiresponses 42 | type DeprovisionResponse = apiresponses.DeprovisionResponse 43 | 44 | // Deprecated: Use code.cloudfoundry.org/brokerapi/v13/domain/apiresponses 45 | type LastOperationResponse = apiresponses.LastOperationResponse 46 | 47 | // Deprecated: Use code.cloudfoundry.org/brokerapi/v13/domain/apiresponses 48 | type AsyncBindResponse = apiresponses.AsyncBindResponse 49 | 50 | // Deprecated: Use code.cloudfoundry.org/brokerapi/v13/domain/apiresponses 51 | type BindingResponse = apiresponses.BindingResponse 52 | 53 | // Deprecated: Use code.cloudfoundry.org/brokerapi/v13/domain/apiresponses 54 | type GetBindingResponse = apiresponses.GetBindingResponse 55 | 56 | // Deprecated: Use code.cloudfoundry.org/brokerapi/v13/domain/apiresponses 57 | type UnbindResponse = apiresponses.UnbindResponse 58 | 59 | // Deprecated: Use code.cloudfoundry.org/brokerapi/v13/domain/apiresponses 60 | type ExperimentalVolumeMountBindingResponse = apiresponses.ExperimentalVolumeMountBindingResponse 61 | 62 | // Deprecated: Use code.cloudfoundry.org/brokerapi/v13/domain 63 | type ExperimentalVolumeMount = domain.ExperimentalVolumeMount 64 | 65 | // Deprecated: Use code.cloudfoundry.org/brokerapi/v13/domain 66 | type ExperimentalVolumeMountPrivate = domain.ExperimentalVolumeMountPrivate 67 | -------------------------------------------------------------------------------- /response_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2015-Present Pivotal Software, Inc. All rights reserved. 2 | 3 | // This program and the accompanying materials are made available under 4 | // the terms of the under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | 16 | package brokerapi_test 17 | 18 | import ( 19 | "encoding/json" 20 | 21 | "code.cloudfoundry.org/brokerapi/v13" 22 | . "github.com/onsi/ginkgo/v2" 23 | . "github.com/onsi/gomega" 24 | ) 25 | 26 | var _ = Describe("Catalog Response", func() { 27 | Describe("JSON encoding", func() { 28 | It("has a list of services", func() { 29 | catalogResponse := brokerapi.CatalogResponse{ 30 | Services: []brokerapi.Service{}, 31 | } 32 | jsonString := `{"services":[]}` 33 | 34 | Expect(json.Marshal(catalogResponse)).To(MatchJSON(jsonString)) 35 | }) 36 | }) 37 | }) 38 | 39 | var _ = Describe("Provisioning Response", func() { 40 | Describe("JSON encoding", func() { 41 | Context("when the dashboard URL is not present", func() { 42 | It("does not return it in the JSON", func() { 43 | provisioningResponse := brokerapi.ProvisioningResponse{} 44 | jsonString := `{}` 45 | 46 | Expect(json.Marshal(provisioningResponse)).To(MatchJSON(jsonString)) 47 | }) 48 | }) 49 | 50 | Context("when the dashboard URL is present", func() { 51 | It("returns it in the JSON", func() { 52 | provisioningResponse := brokerapi.ProvisioningResponse{ 53 | DashboardURL: "http://example.com/broker", 54 | } 55 | jsonString := `{"dashboard_url":"http://example.com/broker"}` 56 | 57 | Expect(json.Marshal(provisioningResponse)).To(MatchJSON(jsonString)) 58 | }) 59 | }) 60 | }) 61 | }) 62 | 63 | var _ = Describe("Update Response", func() { 64 | Describe("JSON encoding", func() { 65 | Context("when the dashboard URL is not present", func() { 66 | It("does not return it in the JSON", func() { 67 | updateResponse := brokerapi.UpdateResponse{} 68 | jsonString := `{}` 69 | 70 | Expect(json.Marshal(updateResponse)).To(MatchJSON(jsonString)) 71 | }) 72 | }) 73 | 74 | Context("when the dashboard URL is present", func() { 75 | It("returns it in the JSON", func() { 76 | updateResponse := brokerapi.UpdateResponse{ 77 | DashboardURL: "http://example.com/broker_updated", 78 | } 79 | jsonString := `{"dashboard_url":"http://example.com/broker_updated"}` 80 | 81 | Expect(json.Marshal(updateResponse)).To(MatchJSON(jsonString)) 82 | }) 83 | }) 84 | }) 85 | }) 86 | 87 | var _ = Describe("Error Response", func() { 88 | Describe("JSON encoding", func() { 89 | It("has a description field", func() { 90 | errorResponse := brokerapi.ErrorResponse{ 91 | Description: "a bad thing happened", 92 | } 93 | jsonString := `{"description":"a bad thing happened"}` 94 | 95 | Expect(json.Marshal(errorResponse)).To(MatchJSON(jsonString)) 96 | }) 97 | }) 98 | }) 99 | -------------------------------------------------------------------------------- /service_broker.go: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2015-Present Pivotal Software, Inc. All rights reserved. 2 | 3 | // This program and the accompanying materials are made available under 4 | // the terms of the under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | 16 | package brokerapi 17 | 18 | import ( 19 | "code.cloudfoundry.org/brokerapi/v13/domain" 20 | "code.cloudfoundry.org/brokerapi/v13/domain/apiresponses" 21 | ) 22 | 23 | //go:generate go run github.com/maxbrunsfeld/counterfeiter/v6 -generate 24 | //counterfeiter:generate -o fakes/auto_fake_service_broker.go -fake-name AutoFakeServiceBroker . ServiceBroker 25 | 26 | // Deprecated: Use code.cloudfoundry.org/brokerapi/v13/domain 27 | // Each method of the ServiceBroker interface maps to an individual endpoint of the Open Service Broker API. 28 | // 29 | // The specification is available here: https://github.com/openservicebrokerapi/servicebroker/blob/v2.14/spec.md 30 | // 31 | // The OpenAPI documentation is available here: http://petstore.swagger.io/?url=https://raw.githubusercontent.com/openservicebrokerapi/servicebroker/v2.14/openapi.yaml 32 | type ServiceBroker interface { 33 | domain.ServiceBroker 34 | } 35 | 36 | // Deprecated: Use code.cloudfoundry.org/brokerapi/v13/domain 37 | type DetailsWithRawParameters interface { 38 | domain.DetailsWithRawParameters 39 | } 40 | 41 | // Deprecated: Use code.cloudfoundry.org/brokerapi/v13/domain 42 | type DetailsWithRawContext interface { 43 | domain.DetailsWithRawContext 44 | } 45 | 46 | // Deprecated: Use code.cloudfoundry.org/brokerapi/v13/domain 47 | type ProvisionDetails = domain.ProvisionDetails 48 | 49 | // Deprecated: Use code.cloudfoundry.org/brokerapi/v13/domain 50 | type ProvisionedServiceSpec = domain.ProvisionedServiceSpec 51 | 52 | // Deprecated: Use code.cloudfoundry.org/brokerapi/v13/domain 53 | type GetInstanceDetailsSpec = domain.GetInstanceDetailsSpec 54 | 55 | // Deprecated: Use code.cloudfoundry.org/brokerapi/v13/domain 56 | type UnbindSpec = domain.UnbindSpec 57 | 58 | // Deprecated: Use code.cloudfoundry.org/brokerapi/v13/domain 59 | type BindDetails = domain.BindDetails 60 | 61 | // Deprecated: Use code.cloudfoundry.org/brokerapi/v13/domain 62 | type BindResource = domain.BindResource 63 | 64 | // Deprecated: Use code.cloudfoundry.org/brokerapi/v13/domain 65 | type UnbindDetails = domain.UnbindDetails 66 | 67 | // Deprecated: Use code.cloudfoundry.org/brokerapi/v13/domain 68 | type UpdateServiceSpec = domain.UpdateServiceSpec 69 | 70 | // Deprecated: Use code.cloudfoundry.org/brokerapi/v13/domain 71 | type DeprovisionServiceSpec = domain.DeprovisionServiceSpec 72 | 73 | // Deprecated: Use code.cloudfoundry.org/brokerapi/v13/domain 74 | type DeprovisionDetails = domain.DeprovisionDetails 75 | 76 | // Deprecated: Use code.cloudfoundry.org/brokerapi/v13/domain 77 | type UpdateDetails = domain.UpdateDetails 78 | 79 | // Deprecated: Use code.cloudfoundry.org/brokerapi/v13/domain 80 | type PreviousValues = domain.PreviousValues 81 | 82 | // Deprecated: Use code.cloudfoundry.org/brokerapi/v13/domain 83 | type PollDetails = domain.PollDetails 84 | 85 | // Deprecated: Use code.cloudfoundry.org/brokerapi/v13/domain 86 | type LastOperation = domain.LastOperation 87 | 88 | // Deprecated: Use code.cloudfoundry.org/brokerapi/v13/domain 89 | type LastOperationState = domain.LastOperationState 90 | 91 | // Deprecated: Use code.cloudfoundry.org/brokerapi/v13/domain 92 | const ( 93 | InProgress LastOperationState = "in progress" 94 | Succeeded LastOperationState = "succeeded" 95 | Failed LastOperationState = "failed" 96 | ) 97 | 98 | // Deprecated: Use code.cloudfoundry.org/brokerapi/v13/domain 99 | type Binding = domain.Binding 100 | 101 | // Deprecated: Use code.cloudfoundry.org/brokerapi/v13/domain 102 | type GetBindingSpec = domain.GetBindingSpec 103 | 104 | // Deprecated: Use code.cloudfoundry.org/brokerapi/v13/domain 105 | type VolumeMount = domain.VolumeMount 106 | 107 | // Deprecated: Use code.cloudfoundry.org/brokerapi/v13/domain 108 | type SharedDevice = domain.SharedDevice 109 | 110 | // Deprecated: Use code.cloudfoundry.org/brokerapi/v13/domain/apiresponses 111 | var ( 112 | ErrInstanceAlreadyExists = apiresponses.ErrInstanceAlreadyExists 113 | 114 | ErrInstanceDoesNotExist = apiresponses.ErrInstanceDoesNotExist 115 | 116 | ErrInstanceNotFound = apiresponses.ErrInstanceNotFound 117 | 118 | ErrInstanceLimitMet = apiresponses.ErrInstanceLimitMet 119 | 120 | ErrBindingAlreadyExists = apiresponses.ErrBindingAlreadyExists 121 | 122 | ErrBindingDoesNotExist = apiresponses.ErrBindingDoesNotExist 123 | 124 | ErrBindingNotFound = apiresponses.ErrBindingNotFound 125 | 126 | ErrAsyncRequired = apiresponses.ErrAsyncRequired 127 | 128 | ErrPlanChangeNotSupported = apiresponses.ErrPlanChangeNotSupported 129 | 130 | ErrRawParamsInvalid = apiresponses.ErrRawParamsInvalid 131 | 132 | ErrAppGuidNotProvided = apiresponses.ErrAppGuidNotProvided 133 | 134 | ErrPlanQuotaExceeded = apiresponses.ErrPlanQuotaExceeded 135 | 136 | ErrServiceQuotaExceeded = apiresponses.ErrServiceQuotaExceeded 137 | 138 | ErrConcurrentInstanceAccess = apiresponses.ErrConcurrentInstanceAccess 139 | 140 | ErrMaintenanceInfoConflict = apiresponses.ErrMaintenanceInfoConflict 141 | 142 | ErrMaintenanceInfoNilConflict = apiresponses.ErrMaintenanceInfoNilConflict 143 | ) 144 | -------------------------------------------------------------------------------- /staticcheck.conf: -------------------------------------------------------------------------------- 1 | # When adding staticcheck, we thought it was better to get it working with some checks disabled 2 | # rather than fixing all the problems in one go. Some problems cannot be fixed without making 3 | # breaking changes. 4 | checks = ["all", "-SA1019", "-ST1000", "-ST1003", "-ST1005", "-ST1012", "-ST1021", "-ST1020"] 5 | -------------------------------------------------------------------------------- /tools/tools.go: -------------------------------------------------------------------------------- 1 | //go:build tools 2 | // +build tools 3 | 4 | package tools 5 | 6 | import ( 7 | _ "github.com/maxbrunsfeld/counterfeiter/v6" 8 | _ "github.com/onsi/ginkgo/v2/ginkgo" 9 | _ "honnef.co/go/tools/cmd/staticcheck" 10 | ) 11 | 12 | // This file imports packages that are used when running go generate, or used 13 | // during the development process but not otherwise depended on by built code. 14 | -------------------------------------------------------------------------------- /utils/context.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "context" 5 | 6 | "code.cloudfoundry.org/brokerapi/v13/domain" 7 | ) 8 | 9 | type contextKey string 10 | 11 | const ( 12 | contextKeyService contextKey = "brokerapi_service" 13 | contextKeyPlan contextKey = "brokerapi_plan" 14 | ) 15 | 16 | func AddServiceToContext(ctx context.Context, service *domain.Service) context.Context { 17 | if service != nil { 18 | return context.WithValue(ctx, contextKeyService, service) 19 | } 20 | return ctx 21 | } 22 | 23 | func RetrieveServiceFromContext(ctx context.Context) *domain.Service { 24 | if value := ctx.Value(contextKeyService); value != nil { 25 | return value.(*domain.Service) 26 | } 27 | return nil 28 | } 29 | 30 | func AddServicePlanToContext(ctx context.Context, plan *domain.ServicePlan) context.Context { 31 | if plan != nil { 32 | return context.WithValue(ctx, contextKeyPlan, plan) 33 | } 34 | return ctx 35 | } 36 | 37 | func RetrieveServicePlanFromContext(ctx context.Context) *domain.ServicePlan { 38 | if value := ctx.Value(contextKeyPlan); value != nil { 39 | return value.(*domain.ServicePlan) 40 | } 41 | return nil 42 | } 43 | -------------------------------------------------------------------------------- /utils/context_test.go: -------------------------------------------------------------------------------- 1 | package utils_test 2 | 3 | import ( 4 | "context" 5 | 6 | "code.cloudfoundry.org/brokerapi/v13/domain" 7 | "code.cloudfoundry.org/brokerapi/v13/utils" 8 | . "github.com/onsi/ginkgo/v2" 9 | . "github.com/onsi/gomega" 10 | ) 11 | 12 | var _ = Describe("Context", func() { 13 | type testContextKey string 14 | 15 | var ( 16 | ctx context.Context 17 | contextValidatorKey testContextKey 18 | contextValidatorValue string 19 | ) 20 | 21 | BeforeEach(func() { 22 | contextValidatorKey = "context-utilities-test" 23 | contextValidatorValue = "original" 24 | ctx = context.Background() 25 | ctx = context.WithValue(ctx, contextValidatorKey, contextValidatorValue) 26 | }) 27 | 28 | Describe("Service Context", func() { 29 | Context("when the service is nil", func() { 30 | It("returns the original context", func() { 31 | ctx = utils.AddServiceToContext(ctx, nil) 32 | Expect(ctx.Err()).To(BeZero()) 33 | Expect(utils.RetrieveServiceFromContext(ctx)).To(BeZero()) 34 | Expect(ctx.Value(contextValidatorKey).(string)).To(Equal(contextValidatorValue)) 35 | }) 36 | }) 37 | 38 | Context("when the service is valid", func() { 39 | It("sets and receives the service in the context", func() { 40 | service := &domain.Service{ 41 | ID: "9A3095D7-ED3C-45FA-BC9F-592820628723", 42 | Name: "Test Service", 43 | } 44 | ctx = utils.AddServiceToContext(ctx, service) 45 | Expect(ctx.Err()).To(BeZero()) 46 | Expect(ctx.Value(contextValidatorKey).(string)).To(Equal(contextValidatorValue)) 47 | Expect(utils.RetrieveServiceFromContext(ctx).ID).To(Equal(service.ID)) 48 | Expect(utils.RetrieveServiceFromContext(ctx).Name).To(Equal(service.Name)) 49 | Expect(utils.RetrieveServiceFromContext(ctx).Metadata).To(BeZero()) 50 | }) 51 | }) 52 | }) 53 | 54 | Describe("Plan Context", func() { 55 | Context("when the service plan is nil", func() { 56 | It("returns the original context", func() { 57 | ctx = utils.AddServicePlanToContext(ctx, nil) 58 | Expect(ctx.Err()).To(BeZero()) 59 | Expect(utils.RetrieveServicePlanFromContext(ctx)).To(BeZero()) 60 | Expect(ctx.Value(contextValidatorKey).(string)).To(Equal(contextValidatorValue)) 61 | }) 62 | }) 63 | 64 | Context("when the service plan is valid", func() { 65 | It("sets and retrieves the service plan in the context", func() { 66 | plan := &domain.ServicePlan{ 67 | ID: "AC257573-8C62-4B1A-AC34-ECA3863F50EC", 68 | } 69 | ctx = utils.AddServicePlanToContext(ctx, plan) 70 | Expect(ctx.Err()).To(BeZero()) 71 | Expect(ctx.Value(contextValidatorKey).(string)).To(Equal(contextValidatorValue)) 72 | Expect(utils.RetrieveServicePlanFromContext(ctx).ID).To(Equal(plan.ID)) 73 | }) 74 | }) 75 | }) 76 | }) 77 | -------------------------------------------------------------------------------- /utils/utils_suite_test.go: -------------------------------------------------------------------------------- 1 | package utils_test 2 | 3 | import ( 4 | "testing" 5 | 6 | . "github.com/onsi/ginkgo/v2" 7 | . "github.com/onsi/gomega" 8 | ) 9 | 10 | func TestUtils(t *testing.T) { 11 | RegisterFailHandler(Fail) 12 | RunSpecs(t, "Utils Suite") 13 | } 14 | --------------------------------------------------------------------------------