├── .buildkite └── pipeline.yml ├── .github ├── CODEOWNERS └── dependabot.yml ├── .gitignore ├── .golangci.yml ├── .licensed.yml ├── .licenses └── go │ ├── github.com │ └── hashicorp │ │ └── go-cleanhttp.dep.yml │ └── golang.org │ └── x │ ├── net │ └── context │ │ └── ctxhttp.dep.yml │ ├── oauth2.dep.yml │ └── oauth2 │ └── internal.dep.yml ├── LICENSE ├── Makefile ├── README.md ├── docker-compose.yml ├── docker └── Dockerfile.licensed ├── go.mod ├── go.sum └── planetscale ├── audit_logs.go ├── audit_logs_test.go ├── backups.go ├── backups_test.go ├── branches.go ├── branches_test.go ├── client.go ├── client_test.go ├── databases.go ├── databases_test.go ├── deploy_requests.go ├── deploy_requests_test.go ├── imports.go ├── imports_test.go ├── integration_test.go ├── keyspaces.go ├── keyspaces_test.go ├── organizations.go ├── organizations_test.go ├── passwords.go ├── passwords_test.go ├── regions.go ├── regions_test.go ├── service_tokens.go ├── service_tokens_test.go ├── workflows.go └── workflows_test.go /.buildkite/pipeline.yml: -------------------------------------------------------------------------------- 1 | agents: 2 | queue: "public" 3 | steps: 4 | - name: "Go build and test" 5 | command: make 6 | plugins: 7 | - docker-compose#v4.14.0: 8 | cli-version: "2" 9 | run: app 10 | 11 | - name: "Check licenses" 12 | command: make licensed 13 | plugins: 14 | - docker-compose#v4.14.0: 15 | cli-version: "2" 16 | run: licensing 17 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @planetscale/surfaces 2 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "gomod" 4 | directory: "/" # Location of package manifests 5 | schedule: 6 | interval: "daily" 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | linters: 3 | enable: 4 | - errorlint 5 | - gosec 6 | - noctx 7 | - unconvert 8 | exclusions: 9 | generated: lax 10 | presets: 11 | - comments 12 | - common-false-positives 13 | - std-error-handling 14 | -------------------------------------------------------------------------------- /.licensed.yml: -------------------------------------------------------------------------------- 1 | sources: 2 | go: true 3 | 4 | source_path: planetscale 5 | 6 | allowed: 7 | - mit 8 | - apache-2.0 9 | - bsd-2-clause 10 | - bsd-3-clause 11 | - mpl-2.0 12 | -------------------------------------------------------------------------------- /.licenses/go/golang.org/x/net/context/ctxhttp.dep.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: golang.org/x/net/context/ctxhttp 3 | version: v0.3.0 4 | type: go 5 | summary: Package ctxhttp provides helper functions for performing context-aware HTTP 6 | requests. 7 | homepage: https://pkg.go.dev/golang.org/x/net/context/ctxhttp 8 | license: bsd-3-clause 9 | licenses: 10 | - sources: net@v0.3.0/LICENSE 11 | text: | 12 | Copyright (c) 2009 The Go Authors. All rights reserved. 13 | 14 | Redistribution and use in source and binary forms, with or without 15 | modification, are permitted provided that the following conditions are 16 | met: 17 | 18 | * Redistributions of source code must retain the above copyright 19 | notice, this list of conditions and the following disclaimer. 20 | * Redistributions in binary form must reproduce the above 21 | copyright notice, this list of conditions and the following disclaimer 22 | in the documentation and/or other materials provided with the 23 | distribution. 24 | * Neither the name of Google Inc. nor the names of its 25 | contributors may be used to endorse or promote products derived from 26 | this software without specific prior written permission. 27 | 28 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 29 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 30 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 31 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 32 | OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 33 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 34 | LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 35 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 36 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 37 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 38 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 39 | - sources: net@v0.3.0/PATENTS 40 | text: | 41 | Additional IP Rights Grant (Patents) 42 | 43 | "This implementation" means the copyrightable works distributed by 44 | Google as part of the Go project. 45 | 46 | Google hereby grants to You a perpetual, worldwide, non-exclusive, 47 | no-charge, royalty-free, irrevocable (except as stated in this section) 48 | patent license to make, have made, use, offer to sell, sell, import, 49 | transfer and otherwise run, modify and propagate the contents of this 50 | implementation of Go, where such license applies only to those patent 51 | claims, both currently owned or controlled by Google and acquired in 52 | the future, licensable by Google that are necessarily infringed by this 53 | implementation of Go. This grant does not include claims that would be 54 | infringed only as a consequence of further modification of this 55 | implementation. If you or your agent or exclusive licensee institute or 56 | order or agree to the institution of patent litigation against any 57 | entity (including a cross-claim or counterclaim in a lawsuit) alleging 58 | that this implementation of Go or any code incorporated within this 59 | implementation of Go constitutes direct or contributory patent 60 | infringement, or inducement of patent infringement, then any patent 61 | rights granted to you under this License for this implementation of Go 62 | shall terminate as of the date such litigation is filed. 63 | notices: [] 64 | -------------------------------------------------------------------------------- /.licenses/go/golang.org/x/oauth2.dep.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: golang.org/x/oauth2 3 | version: v0.3.0 4 | type: go 5 | summary: Package oauth2 provides support for making OAuth2 authorized and authenticated 6 | HTTP requests, as specified in RFC 6749. 7 | homepage: https://pkg.go.dev/golang.org/x/oauth2 8 | license: bsd-3-clause 9 | licenses: 10 | - sources: LICENSE 11 | text: | 12 | Copyright (c) 2009 The Go Authors. All rights reserved. 13 | 14 | Redistribution and use in source and binary forms, with or without 15 | modification, are permitted provided that the following conditions are 16 | met: 17 | 18 | * Redistributions of source code must retain the above copyright 19 | notice, this list of conditions and the following disclaimer. 20 | * Redistributions in binary form must reproduce the above 21 | copyright notice, this list of conditions and the following disclaimer 22 | in the documentation and/or other materials provided with the 23 | distribution. 24 | * Neither the name of Google Inc. nor the names of its 25 | contributors may be used to endorse or promote products derived from 26 | this software without specific prior written permission. 27 | 28 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 29 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 30 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 31 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 32 | OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 33 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 34 | LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 35 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 36 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 37 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 38 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 39 | notices: [] 40 | -------------------------------------------------------------------------------- /.licenses/go/golang.org/x/oauth2/internal.dep.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: golang.org/x/oauth2/internal 3 | version: v0.3.0 4 | type: go 5 | summary: Package internal contains support packages for oauth2 package. 6 | homepage: https://pkg.go.dev/golang.org/x/oauth2/internal 7 | license: bsd-3-clause 8 | licenses: 9 | - sources: oauth2@v0.3.0/LICENSE 10 | text: | 11 | Copyright (c) 2009 The Go Authors. All rights reserved. 12 | 13 | Redistribution and use in source and binary forms, with or without 14 | modification, are permitted provided that the following conditions are 15 | met: 16 | 17 | * Redistributions of source code must retain the above copyright 18 | notice, this list of conditions and the following disclaimer. 19 | * Redistributions in binary form must reproduce the above 20 | copyright notice, this list of conditions and the following disclaimer 21 | in the documentation and/or other materials provided with the 22 | distribution. 23 | * Neither the name of Google Inc. nor the names of its 24 | contributors may be used to endorse or promote products derived from 25 | this software without specific prior written permission. 26 | 27 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 28 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 29 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 30 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 31 | OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 32 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 33 | LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 34 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 35 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 36 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 37 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 38 | notices: [] 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright 2021 PlanetScale, Inc. 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: all 2 | all: build test lint 3 | 4 | .PHONY: test 5 | test: 6 | go test ./... 7 | 8 | .PHONY: build 9 | build: 10 | go build ./... 11 | 12 | .PHONY: lint 13 | lint: 14 | @go install honnef.co/go/tools/cmd/staticcheck@latest 15 | @staticcheck ./... 16 | @go vet ./... 17 | 18 | .PHONY: licensed 19 | licensed: 20 | licensed cache 21 | licensed status 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # planetscale-go [![Go Reference](https://pkg.go.dev/badge/github.com/planetscale/planetscale-go/planetscale.svg)](https://pkg.go.dev/github.com/planetscale/planetscale-go/planetscale) [![Build status](https://badge.buildkite.com/82dafa9518fe94b3fed75db71bcfc3836faeec49816e400f2e.svg?branch=main)](https://buildkite.com/planetscale/planetscale-go) 2 | 3 | Go package to access the PlanetScale API. 4 | 5 | ## Install 6 | 7 | ``` 8 | go get github.com/planetscale/planetscale-go/planetscale 9 | ``` 10 | 11 | ## Usage 12 | 13 | Here is an example application using the PlanetScale Go client. You can create 14 | and manage your service tokens via our [pscale 15 | CLI](https://github.com/planetscale/cli) with the `pscale service-token` 16 | subcommand. 17 | 18 | ```go 19 | package main 20 | 21 | import ( 22 | "context" 23 | "log" 24 | "os" 25 | "time" 26 | 27 | "github.com/planetscale/planetscale-go/planetscale" 28 | ) 29 | 30 | func main() { 31 | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) 32 | defer cancel() 33 | 34 | // Create a new PlanetScale API client with the given service token. 35 | client, err := planetscale.NewClient( 36 | planetscale.WithServiceToken("token-id", os.Getenv("PLANETSCALE_TOKEN")), 37 | ) 38 | if err != nil { 39 | log.Fatalf("failed to create client: %v", err) 40 | } 41 | 42 | // Create a new database. 43 | _, err = client.Databases.Create(ctx, &planetscale.CreateDatabaseRequest{ 44 | Organization: "my-org", 45 | Name: "my-awesome-database", 46 | Notes: "This is a test DB created via the planetscale-go API library", 47 | }) 48 | if err != nil { 49 | log.Fatalf("failed to create database: %v", err) 50 | } 51 | 52 | // List all databases for the given organization. 53 | databases, err := client.Databases.List(ctx, &planetscale.ListDatabasesRequest{ 54 | Organization: "my-org", 55 | }) 56 | if err != nil { 57 | log.Fatalf("failed to list databases: %v", err) 58 | } 59 | 60 | log.Printf("found %d databases:", len(databases)) 61 | for _, db := range databases { 62 | log.Printf(" - %q: %s", db.Name, db.Notes) 63 | } 64 | 65 | // Delete a database. 66 | _, err = client.Databases.Delete(ctx, &planetscale.DeleteDatabaseRequest{ 67 | Organization: "my-org", 68 | Database: "my-awesome-database", 69 | }) 70 | if err != nil { 71 | log.Fatalf("failed to delete database: %v", err) 72 | } 73 | } 74 | ``` 75 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | 3 | services: 4 | app: 5 | image: golang:1.24 6 | volumes: 7 | - .:/work 8 | working_dir: /work 9 | 10 | licensing: 11 | build: 12 | context: ./docker 13 | dockerfile: Dockerfile.licensed 14 | volumes: 15 | - .:/work 16 | working_dir: /work 17 | -------------------------------------------------------------------------------- /docker/Dockerfile.licensed: -------------------------------------------------------------------------------- 1 | FROM golang:1.24 2 | 3 | RUN apt-get update && apt-get upgrade -y 4 | RUN apt-get install -y xz-utils ruby-dev rubygems ruby cmake pkg-config git-core libgit2-dev 5 | RUN gem install licensed 6 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/planetscale/planetscale-go 2 | 3 | go 1.24.1 4 | 5 | require ( 6 | github.com/frankban/quicktest v1.14.6 7 | github.com/hashicorp/go-cleanhttp v0.5.2 8 | golang.org/x/oauth2 v0.30.0 9 | ) 10 | 11 | require ( 12 | github.com/google/go-cmp v0.5.9 // indirect 13 | github.com/kr/pretty v0.3.1 // indirect 14 | github.com/kr/text v0.2.0 // indirect 15 | github.com/rogpeppe/go-internal v1.9.0 // indirect 16 | ) 17 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 2 | github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= 3 | github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= 4 | github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= 5 | github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 6 | github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= 7 | github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= 8 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 9 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 10 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 11 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 12 | github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= 13 | github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= 14 | github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= 15 | golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= 16 | golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= 17 | -------------------------------------------------------------------------------- /planetscale/audit_logs.go: -------------------------------------------------------------------------------- 1 | package planetscale 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "net/http" 8 | "time" 9 | ) 10 | 11 | // AuditLogEvent represents an audit log's event type 12 | type AuditLogEvent string 13 | 14 | const ( 15 | AuditLogEventBranchCreated AuditLogEvent = "branch.created" 16 | AuditLogEventBranchDeleted AuditLogEvent = "branch.deleted" 17 | AuditLogEventDatabaseCreated AuditLogEvent = "database.created" 18 | AuditLogEventDatabaseDeleted AuditLogEvent = "database.deleted" 19 | AuditLogEventDeployRequestApproved AuditLogEvent = "deploy_request.approved" 20 | AuditLogEventDeployRequestClosed AuditLogEvent = "deploy_request.closed" 21 | AuditLogEventDeployRequestCreated AuditLogEvent = "deploy_request.created" 22 | AuditLogEventDeployRequestDeleted AuditLogEvent = "deploy_request.deleted" 23 | AuditLogEventDeployRequestQueued AuditLogEvent = "deploy_request.queued" 24 | AuditLogEventDeployRequestUnqueued AuditLogEvent = "deploy_request.unqueued" 25 | AuditLogEventIntegrationCreated AuditLogEvent = "integration.created" 26 | AuditLogEventIntegrationDeleted AuditLogEvent = "integration.deleted" 27 | AuditLogEventOrganizationInvitationCreated AuditLogEvent = "organization_invitation.created" 28 | AuditLogEventOrganizationInvitationDeleted AuditLogEvent = "organization_invitation.deleted" 29 | AuditLogEventOrganizationMembershipCreated AuditLogEvent = "organization_membership.created" 30 | AuditLogEventOrganizationJoined AuditLogEvent = "organization.joined" 31 | AuditLogEventOrganizationRemovedMember AuditLogEvent = "organization.removed_member" 32 | AuditLogEventOrganizationDisabledSSO AuditLogEvent = "organization.disabled_sso" 33 | AuditLogEventOrganizationEnabledSSO AuditLogEvent = "organization.enabled_sso" 34 | AuditLogEventOrganizationUpdatedRole AuditLogEvent = "organization.updated_role" 35 | AuditLogEventServiceTokenCreated AuditLogEvent = "service_token.created" 36 | AuditLogEventServiceTokenDeleted AuditLogEvent = "service_token.deleted" 37 | AuditLogEventServiceTokenGrantedAccess AuditLogEvent = "service_token.granted_access" 38 | ) 39 | 40 | var _ AuditLogsService = &auditlogsService{} 41 | 42 | // AuditLogsService is an interface for communicating with the PlanetScale 43 | // AuditLogs API endpoints. 44 | type AuditLogsService interface { 45 | List(context.Context, *ListAuditLogsRequest, ...ListOption) (*CursorPaginatedResponse[*AuditLog], error) 46 | } 47 | 48 | // ListAuditLogsRequest encapsulates the request for listing the audit logs of 49 | // an organization. 50 | type ListAuditLogsRequest struct { 51 | Organization string 52 | 53 | // Events can be used to filter out only the given audit log events. 54 | Events []AuditLogEvent 55 | } 56 | 57 | // AuditLog represents a PlanetScale audit log. 58 | type AuditLog struct { 59 | ID string `json:"id"` 60 | Type string `json:"type"` 61 | 62 | ActorID string `json:"actor_id"` 63 | ActorType string `json:"actor_type"` 64 | ActorDisplayName string `json:"actor_display_name"` 65 | 66 | AuditableID string `json:"auditable_id"` 67 | AuditableType string `json:"auditable_type"` 68 | AuditableDisplayName string `json:"auditable_display_name"` 69 | 70 | AuditAction string `json:"audit_action"` 71 | Action string `json:"action"` 72 | 73 | Location string `json:"location"` 74 | RemoteIP string `json:"remote_ip"` 75 | 76 | TargetID string `json:"target_id"` 77 | TargetType string `json:"target_type"` 78 | TargetDisplayName string `json:"target_display_name"` 79 | 80 | Metadata map[string]interface{} `json:"metadata"` 81 | 82 | CreatedAt time.Time `json:"created_at"` 83 | UpdatedAt time.Time `json:"updated_at"` 84 | } 85 | 86 | type auditlogsService struct { 87 | client *Client 88 | } 89 | 90 | func NewAuditLogsService(client *Client) *auditlogsService { 91 | return &auditlogsService{ 92 | client: client, 93 | } 94 | } 95 | 96 | // WithEventFilters sets filters on a set of list filters from audit log events. 97 | // For example, `audit_action:database.created`, 98 | // `audit_action:database.deleted`, etc. 99 | func WithEventFilters(events []AuditLogEvent) ListOption { 100 | return func(opt *ListOptions) error { 101 | values := opt.URLValues 102 | if len(events) != 0 { 103 | for _, action := range events { 104 | values.Add("filters[]", fmt.Sprintf("audit_action:%s", action)) 105 | } 106 | } 107 | return nil 108 | } 109 | } 110 | 111 | // List returns the audit logs for an organization. 112 | func (o *auditlogsService) List(ctx context.Context, listReq *ListAuditLogsRequest, opts ...ListOption) (*CursorPaginatedResponse[*AuditLog], error) { 113 | if listReq.Organization == "" { 114 | return nil, errors.New("organization is not set") 115 | } 116 | 117 | path := auditlogsAPIPath(listReq.Organization) 118 | 119 | defaultOpts := defaultListOptions(WithEventFilters(listReq.Events)) 120 | for _, opt := range opts { 121 | err := opt(defaultOpts) 122 | if err != nil { 123 | return nil, err 124 | } 125 | } 126 | 127 | if vals := defaultOpts.URLValues.Encode(); vals != "" { 128 | path += "?" + vals 129 | } 130 | 131 | req, err := o.client.newRequest(http.MethodGet, path, nil) 132 | if err != nil { 133 | return nil, fmt.Errorf("error creating request for listing audit logs: %w", err) 134 | } 135 | 136 | resp := &CursorPaginatedResponse[*AuditLog]{} 137 | if err := o.client.do(ctx, req, &resp); err != nil { 138 | return nil, err 139 | } 140 | 141 | return resp, nil 142 | } 143 | 144 | func auditlogsAPIPath(org string) string { 145 | return fmt.Sprintf("%s/%s/audit-log", organizationsAPIPath, org) 146 | } 147 | -------------------------------------------------------------------------------- /planetscale/audit_logs_test.go: -------------------------------------------------------------------------------- 1 | package planetscale 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "net/http/httptest" 7 | "testing" 8 | "time" 9 | 10 | qt "github.com/frankban/quicktest" 11 | ) 12 | 13 | func TestAuditLogs_List(t *testing.T) { 14 | c := qt.New(t) 15 | 16 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 17 | w.WriteHeader(200) 18 | out := `{ 19 | "type": "list", 20 | "has_next": true, 21 | "has_prev": false, 22 | "cursor_start": "ecxuvovgfo95", 23 | "cursor_end": "ecxuvovgfo95", 24 | "data": [ 25 | { 26 | "id": "ecxuvovgfo95", 27 | "type": "AuditLogEvent", 28 | "actor_id": "d4hkujnkswjk", 29 | "actor_type": "User", 30 | "auditable_id": "kbog8qlq6lp4", 31 | "auditable_type": "DeployRequest", 32 | "target_id": "m40xz7x6gvvk", 33 | "target_type": "Database", 34 | "location": "Chicago, IL", 35 | "target_display_name": "planetscale", 36 | "metadata": { 37 | "from": "add-name-to-service-tokens", 38 | "into": "main" 39 | }, 40 | "audit_action": "deploy_request.queued", 41 | "action": "queued", 42 | "actor_display_name": "Elom Gomez", 43 | "auditable_display_name": "deploy request #102", 44 | "remote_ip": "45.19.24.124", 45 | "created_at": "2021-07-19T17:13:45.000Z", 46 | "updated_at": "2021-07-19T17:13:45.000Z" 47 | } 48 | ] 49 | }` 50 | _, err := w.Write([]byte(out)) 51 | c.Assert(err, qt.IsNil) 52 | })) 53 | 54 | client, err := NewClient(WithBaseURL(ts.URL)) 55 | c.Assert(err, qt.IsNil) 56 | 57 | ctx := context.Background() 58 | 59 | auditLogs, err := client.AuditLogs.List(ctx, &ListAuditLogsRequest{ 60 | Organization: testOrg, 61 | Events: []AuditLogEvent{ 62 | AuditLogEventBranchDeleted, 63 | AuditLogEventOrganizationJoined, 64 | }, 65 | }) 66 | 67 | auditLogID := "ecxuvovgfo95" 68 | want := &CursorPaginatedResponse[*AuditLog]{ 69 | Data: []*AuditLog{ 70 | { 71 | ID: "ecxuvovgfo95", 72 | Type: "AuditLogEvent", 73 | ActorID: "d4hkujnkswjk", 74 | ActorType: "User", 75 | AuditableID: "kbog8qlq6lp4", 76 | AuditableType: "DeployRequest", 77 | TargetID: "m40xz7x6gvvk", 78 | TargetType: "Database", 79 | Location: "Chicago, IL", 80 | TargetDisplayName: "planetscale", 81 | Metadata: map[string]interface{}{ 82 | "from": "add-name-to-service-tokens", 83 | "into": "main", 84 | }, 85 | AuditAction: "deploy_request.queued", 86 | Action: "queued", 87 | ActorDisplayName: "Elom Gomez", 88 | AuditableDisplayName: "deploy request #102", 89 | RemoteIP: "45.19.24.124", 90 | CreatedAt: time.Date(2021, time.July, 19, 17, 13, 45, 0, time.UTC), 91 | UpdatedAt: time.Date(2021, time.July, 19, 17, 13, 45, 0, time.UTC), 92 | }, 93 | }, 94 | HasNext: true, 95 | HasPrev: false, 96 | CursorStart: &auditLogID, 97 | CursorEnd: &auditLogID, 98 | } 99 | 100 | c.Assert(err, qt.IsNil) 101 | c.Assert(auditLogs, qt.DeepEquals, want) 102 | } 103 | -------------------------------------------------------------------------------- /planetscale/backups.go: -------------------------------------------------------------------------------- 1 | package planetscale 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | "time" 8 | ) 9 | 10 | type Backup struct { 11 | PublicID string `json:"id"` 12 | Name string `json:"name"` 13 | State string `json:"state"` 14 | Size int64 `json:"size"` 15 | Actor *Actor `json:"actor"` 16 | CreatedAt time.Time `json:"created_at"` 17 | UpdatedAt time.Time `json:"updated_at"` 18 | StartedAt time.Time `json:"started_at"` 19 | ExpiresAt time.Time `json:"expires_at"` 20 | CompletedAt time.Time `json:"completed_at"` 21 | } 22 | 23 | type backupsResponse struct { 24 | Backups []*Backup `json:"data"` 25 | } 26 | 27 | type CreateBackupRequest struct { 28 | Organization string `json:"-"` 29 | Database string `json:"-"` 30 | Branch string `json:"-"` 31 | } 32 | 33 | type ListBackupsRequest struct { 34 | Organization string 35 | Database string 36 | Branch string 37 | } 38 | 39 | type GetBackupRequest struct { 40 | Organization string 41 | Database string 42 | Branch string 43 | Backup string 44 | } 45 | 46 | type DeleteBackupRequest struct { 47 | Organization string 48 | Database string 49 | Branch string 50 | Backup string 51 | } 52 | 53 | // BackupsService is an interface for communicating with the PlanetScale 54 | // backup API endpoint. 55 | type BackupsService interface { 56 | Create(context.Context, *CreateBackupRequest) (*Backup, error) 57 | List(context.Context, *ListBackupsRequest) ([]*Backup, error) 58 | Get(context.Context, *GetBackupRequest) (*Backup, error) 59 | Delete(context.Context, *DeleteBackupRequest) error 60 | } 61 | 62 | type backupsService struct { 63 | client *Client 64 | } 65 | 66 | var _ BackupsService = &backupsService{} 67 | 68 | func NewBackupsService(client *Client) *backupsService { 69 | return &backupsService{ 70 | client: client, 71 | } 72 | } 73 | 74 | // Creates a new backup for a branch. 75 | func (d *backupsService) Create(ctx context.Context, createReq *CreateBackupRequest) (*Backup, error) { 76 | path := backupsAPIPath(createReq.Organization, createReq.Database, createReq.Branch) 77 | req, err := d.client.newRequest(http.MethodPost, path, nil) 78 | if err != nil { 79 | return nil, fmt.Errorf("error creating http request: %w", err) 80 | } 81 | 82 | backup := &Backup{} 83 | if err := d.client.do(ctx, req, &backup); err != nil { 84 | return nil, err 85 | } 86 | 87 | return backup, nil 88 | } 89 | 90 | // Returns a single backup for a branch. 91 | func (d *backupsService) Get(ctx context.Context, getReq *GetBackupRequest) (*Backup, error) { 92 | path := backupAPIPath(getReq.Organization, getReq.Database, getReq.Branch, getReq.Backup) 93 | req, err := d.client.newRequest(http.MethodGet, path, nil) 94 | if err != nil { 95 | return nil, fmt.Errorf("error creating http request: %w", err) 96 | } 97 | 98 | backup := &Backup{} 99 | if err := d.client.do(ctx, req, &backup); err != nil { 100 | return nil, err 101 | } 102 | 103 | return backup, nil 104 | } 105 | 106 | // Returns all of the backups for a branch. 107 | func (d *backupsService) List(ctx context.Context, listReq *ListBackupsRequest) ([]*Backup, error) { 108 | req, err := d.client.newRequest(http.MethodGet, backupsAPIPath(listReq.Organization, listReq.Database, listReq.Branch), nil) 109 | if err != nil { 110 | return nil, fmt.Errorf("error creating http request: %w", err) 111 | } 112 | 113 | backups := &backupsResponse{} 114 | if err := d.client.do(ctx, req, &backups); err != nil { 115 | return nil, err 116 | } 117 | 118 | return backups.Backups, nil 119 | } 120 | 121 | // Deletes a branch backup. 122 | func (d *backupsService) Delete(ctx context.Context, deleteReq *DeleteBackupRequest) error { 123 | path := backupAPIPath(deleteReq.Organization, deleteReq.Database, deleteReq.Branch, deleteReq.Backup) 124 | req, err := d.client.newRequest(http.MethodDelete, path, nil) 125 | if err != nil { 126 | return fmt.Errorf("error creating http request: %w", err) 127 | } 128 | 129 | err = d.client.do(ctx, req, nil) 130 | return err 131 | } 132 | 133 | func backupsAPIPath(org, db, branch string) string { 134 | return fmt.Sprintf("%s/backups", databaseBranchAPIPath(org, db, branch)) 135 | } 136 | 137 | func backupAPIPath(org, db, branch, backup string) string { 138 | return fmt.Sprintf("%s/%s", backupsAPIPath(org, db, branch), backup) 139 | } 140 | -------------------------------------------------------------------------------- /planetscale/backups_test.go: -------------------------------------------------------------------------------- 1 | package planetscale 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "net/http/httptest" 7 | "testing" 8 | "time" 9 | 10 | qt "github.com/frankban/quicktest" 11 | ) 12 | 13 | const testBackup = "planetscale-go-test-backup" 14 | 15 | func TestBackups_Create(t *testing.T) { 16 | c := qt.New(t) 17 | 18 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 19 | w.WriteHeader(200) 20 | out := `{"id":"planetscale-go-test-backup","type":"backup","name":"planetscale-go-test-backup","created_at":"2021-01-14T10:19:23.000Z","updated_at":"2021-01-14T10:19:23.000Z"}` 21 | _, err := w.Write([]byte(out)) 22 | c.Assert(err, qt.IsNil) 23 | })) 24 | 25 | client, err := NewClient(WithBaseURL(ts.URL)) 26 | c.Assert(err, qt.IsNil) 27 | 28 | ctx := context.Background() 29 | org := "my-org" 30 | db := "my-db" 31 | branch := "my-branch" 32 | 33 | backup, err := client.Backups.Create(ctx, &CreateBackupRequest{ 34 | Organization: org, 35 | Database: db, 36 | Branch: branch, 37 | }) 38 | 39 | want := &Backup{ 40 | PublicID: "planetscale-go-test-backup", 41 | Name: testBackup, 42 | CreatedAt: time.Date(2021, time.January, 14, 10, 19, 23, 0, time.UTC), 43 | UpdatedAt: time.Date(2021, time.January, 14, 10, 19, 23, 0, time.UTC), 44 | } 45 | 46 | c.Assert(err, qt.IsNil) 47 | c.Assert(backup, qt.DeepEquals, want) 48 | } 49 | 50 | func TestBackups_List(t *testing.T) { 51 | c := qt.New(t) 52 | 53 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 54 | w.WriteHeader(200) 55 | out := `{"data":[{"id":"planetscale-go-test-backup","type":"backup","name":"planetscale-go-test-backup","created_at":"2021-01-14T10:19:23.000Z","updated_at":"2021-01-14T10:19:23.000Z"}]}` 56 | _, err := w.Write([]byte(out)) 57 | c.Assert(err, qt.IsNil) 58 | })) 59 | 60 | client, err := NewClient(WithBaseURL(ts.URL)) 61 | c.Assert(err, qt.IsNil) 62 | 63 | ctx := context.Background() 64 | org := "my-org" 65 | db := "planetscale-go-test-db" 66 | branch := "my-branch" 67 | 68 | backups, err := client.Backups.List(ctx, &ListBackupsRequest{ 69 | Organization: org, 70 | Database: db, 71 | Branch: branch, 72 | }) 73 | 74 | want := []*Backup{{ 75 | PublicID: "planetscale-go-test-backup", 76 | Name: testBackup, 77 | CreatedAt: time.Date(2021, time.January, 14, 10, 19, 23, 0, time.UTC), 78 | UpdatedAt: time.Date(2021, time.January, 14, 10, 19, 23, 0, time.UTC), 79 | }} 80 | 81 | c.Assert(err, qt.IsNil) 82 | c.Assert(backups, qt.DeepEquals, want) 83 | } 84 | 85 | func TestBackups_ListEmpty(t *testing.T) { 86 | c := qt.New(t) 87 | 88 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 89 | w.WriteHeader(200) 90 | out := `{"data":[]}` 91 | _, err := w.Write([]byte(out)) 92 | c.Assert(err, qt.IsNil) 93 | })) 94 | 95 | client, err := NewClient(WithBaseURL(ts.URL)) 96 | c.Assert(err, qt.IsNil) 97 | 98 | ctx := context.Background() 99 | org := "my-org" 100 | db := "planetscale-go-test-db" 101 | branch := "my-branch" 102 | 103 | backups, err := client.Backups.List(ctx, &ListBackupsRequest{ 104 | Organization: org, 105 | Database: db, 106 | Branch: branch, 107 | }) 108 | 109 | c.Assert(err, qt.IsNil) 110 | c.Assert(backups, qt.HasLen, 0) 111 | } 112 | 113 | func TestBackups_Get(t *testing.T) { 114 | c := qt.New(t) 115 | 116 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 117 | w.WriteHeader(200) 118 | out := `{"id":"planetscale-go-test-backup","type":"backup","name":"planetscale-go-test-backup","created_at":"2021-01-14T10:19:23.000Z","updated_at":"2021-01-14T10:19:23.000Z"}` 119 | _, err := w.Write([]byte(out)) 120 | c.Assert(err, qt.IsNil) 121 | })) 122 | 123 | client, err := NewClient(WithBaseURL(ts.URL)) 124 | c.Assert(err, qt.IsNil) 125 | 126 | ctx := context.Background() 127 | org := "my-org" 128 | db := "planetscale-go-test-db" 129 | branch := "my-branch" 130 | 131 | backup, err := client.Backups.Get(ctx, &GetBackupRequest{ 132 | Organization: org, 133 | Database: db, 134 | Branch: branch, 135 | Backup: testBackup, 136 | }) 137 | 138 | want := &Backup{ 139 | PublicID: "planetscale-go-test-backup", 140 | Name: testBackup, 141 | CreatedAt: time.Date(2021, time.January, 14, 10, 19, 23, 0, time.UTC), 142 | UpdatedAt: time.Date(2021, time.January, 14, 10, 19, 23, 0, time.UTC), 143 | } 144 | 145 | c.Assert(err, qt.IsNil) 146 | c.Assert(backup, qt.DeepEquals, want) 147 | } 148 | -------------------------------------------------------------------------------- /planetscale/client.go: -------------------------------------------------------------------------------- 1 | package planetscale 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "errors" 8 | "fmt" 9 | "io" 10 | "net/http" 11 | "net/url" 12 | "runtime/debug" 13 | "strconv" 14 | "sync" 15 | 16 | "github.com/hashicorp/go-cleanhttp" 17 | "golang.org/x/oauth2" 18 | ) 19 | 20 | const ( 21 | DefaultBaseURL = "https://api.planetscale.com/" 22 | jsonMediaType = "application/json" 23 | ) 24 | 25 | // ErrorCode defines the code of an error. 26 | type ErrorCode string 27 | 28 | const ( 29 | ErrInternal ErrorCode = "internal" // Internal error. 30 | ErrInvalid ErrorCode = "invalid" // Invalid operation, e.g wrong params 31 | ErrPermission ErrorCode = "permission" // Permission denied. 32 | ErrNotFound ErrorCode = "not_found" // Resource not found. 33 | ErrRetry ErrorCode = "retry" // Operation should be retried. 34 | ErrResponseMalformed ErrorCode = "response_malformed" // Response body is malformed. 35 | ) 36 | 37 | // Client encapsulates a client that talks to the PlanetScale API 38 | type Client struct { 39 | // client represents the HTTP client used for making HTTP requests. 40 | client *http.Client 41 | 42 | // UserAgent is the version of the planetscale-go library that is being used 43 | UserAgent string 44 | 45 | // headers are used to override request headers for every single HTTP request 46 | headers map[string]string 47 | 48 | // base URL for the API 49 | baseURL *url.URL 50 | 51 | AuditLogs AuditLogsService 52 | Backups BackupsService 53 | Databases DatabasesService 54 | DatabaseBranches DatabaseBranchesService 55 | DataImports DataImportsService 56 | Organizations OrganizationsService 57 | Passwords PasswordsService 58 | Regions RegionsService 59 | DeployRequests DeployRequestsService 60 | ServiceTokens ServiceTokenService 61 | Keyspaces KeyspacesService 62 | Workflows WorkflowsService 63 | } 64 | 65 | // ListOptions are options for listing responses. 66 | type ListOptions struct { 67 | URLValues *url.Values 68 | } 69 | 70 | type ListOption func(*ListOptions) error 71 | 72 | // DefaultListOptions returns the default list options values. 73 | func defaultListOptions(opts ...ListOption) *ListOptions { 74 | listOpts := &ListOptions{ 75 | URLValues: &url.Values{}, 76 | } 77 | 78 | for _, opt := range opts { 79 | err := opt(listOpts) 80 | if err != nil { 81 | panic(err) 82 | } 83 | } 84 | 85 | return listOpts 86 | } 87 | 88 | // WithStartingAfter returns a ListOption that sets the "starting_after" URL parameter. 89 | func WithStartingAfter(startingAfter string) ListOption { 90 | return func(opt *ListOptions) error { 91 | if startingAfter != "" { 92 | opt.URLValues.Set("starting_after", startingAfter) 93 | } 94 | return nil 95 | } 96 | } 97 | 98 | // WithLimit returns a ListOption that sets the "limit" URL parameter. 99 | func WithLimit(limit int) ListOption { 100 | return func(opt *ListOptions) error { 101 | if limit > 0 { 102 | limitStr := strconv.Itoa(limit) 103 | opt.URLValues.Set("limit", limitStr) 104 | } 105 | return nil 106 | } 107 | } 108 | 109 | // WithRates returns a ListOption that sets the "rates" URL parameter. 110 | func WithRates() ListOption { 111 | return func(opt *ListOptions) error { 112 | opt.URLValues.Set("rates", "true") 113 | return nil 114 | } 115 | } 116 | 117 | // WithRegion returns a ListOption sets the "region" URL parameter. 118 | func WithRegion(region string) ListOption { 119 | return func(opt *ListOptions) error { 120 | if len(region) > 0 { 121 | opt.URLValues.Set("region", region) 122 | } 123 | return nil 124 | } 125 | } 126 | 127 | // WithPage returns a ListOption that sets the "page" URL parameter. 128 | func WithPage(page int) ListOption { 129 | return func(opt *ListOptions) error { 130 | if page > 0 { 131 | pageStr := strconv.Itoa(page) 132 | opt.URLValues.Set("page", pageStr) 133 | } 134 | return nil 135 | } 136 | } 137 | 138 | // WithPerPage returns a ListOption that sets the "per_page" URL paramter. 139 | func WithPerPage(perPage int) ListOption { 140 | return func(opt *ListOptions) error { 141 | if perPage > 0 { 142 | perPageStr := strconv.Itoa(perPage) 143 | opt.URLValues.Set("per_page", perPageStr) 144 | } 145 | return nil 146 | } 147 | } 148 | 149 | // ClientOption provides a variadic option for configuring the client 150 | type ClientOption func(c *Client) error 151 | 152 | var defaultUserAgent = sync.OnceValue(func() string { 153 | libraryVersion := "unknown" 154 | buildInfo, ok := debug.ReadBuildInfo() 155 | if ok { 156 | for _, dep := range buildInfo.Deps { 157 | if dep.Path == "github.com/planetscale/planetscale-go" { 158 | libraryVersion = dep.Version 159 | break 160 | } 161 | } 162 | } 163 | 164 | return "planetscale-go/" + libraryVersion 165 | }) 166 | 167 | // WithUserAgent overrides the User-Agent header. 168 | func WithUserAgent(userAgent string) ClientOption { 169 | return func(c *Client) error { 170 | c.UserAgent = fmt.Sprintf("%s %s", userAgent, c.UserAgent) 171 | return nil 172 | } 173 | } 174 | 175 | // WithRequestHeaders sets the request headers for every HTTP request. 176 | func WithRequestHeaders(headers map[string]string) ClientOption { 177 | return func(c *Client) error { 178 | for k, v := range headers { 179 | c.headers[k] = v 180 | } 181 | 182 | return nil 183 | } 184 | } 185 | 186 | // WithBaseURL overrides the base URL for the API. 187 | func WithBaseURL(baseURL string) ClientOption { 188 | return func(c *Client) error { 189 | parsedURL, err := url.Parse(baseURL) 190 | if err != nil { 191 | return err 192 | } 193 | 194 | c.baseURL = parsedURL 195 | return nil 196 | } 197 | } 198 | 199 | // WithAccessToken configures a client with the given PlanetScale access token. 200 | func WithAccessToken(token string) ClientOption { 201 | return func(c *Client) error { 202 | if token == "" { 203 | return errors.New("missing access token") 204 | } 205 | 206 | tokenSource := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: token}) 207 | 208 | // make sure we use our own HTTP client 209 | ctx := context.WithValue(context.Background(), oauth2.HTTPClient, c.client) 210 | oauthClient := oauth2.NewClient(ctx, tokenSource) 211 | 212 | c.client = oauthClient 213 | return nil 214 | } 215 | } 216 | 217 | // WithServiceToken configures a client with the given PlanetScale Service Token 218 | func WithServiceToken(name, token string) ClientOption { 219 | return func(c *Client) error { 220 | if token == "" || name == "" { 221 | return errors.New("missing token name and string") 222 | } 223 | 224 | transport := serviceTokenTransport{ 225 | rt: c.client.Transport, 226 | token: token, 227 | tokenName: name, 228 | } 229 | 230 | c.client.Transport = &transport 231 | return nil 232 | } 233 | } 234 | 235 | // WithHTTPClient configures the PlanetScale client with the given HTTP client. 236 | func WithHTTPClient(client *http.Client) ClientOption { 237 | return func(c *Client) error { 238 | if client == nil { 239 | client = cleanhttp.DefaultClient() 240 | } 241 | 242 | c.client = client 243 | return nil 244 | } 245 | } 246 | 247 | // NewClient instantiates an instance of the PlanetScale API client. 248 | func NewClient(opts ...ClientOption) (*Client, error) { 249 | baseURL, err := url.Parse(DefaultBaseURL) 250 | if err != nil { 251 | return nil, err 252 | } 253 | 254 | c := &Client{ 255 | client: cleanhttp.DefaultClient(), 256 | baseURL: baseURL, 257 | UserAgent: defaultUserAgent(), 258 | headers: make(map[string]string, 0), 259 | } 260 | 261 | for _, opt := range opts { 262 | err := opt(c) 263 | if err != nil { 264 | return nil, err 265 | } 266 | } 267 | 268 | c.AuditLogs = &auditlogsService{client: c} 269 | c.Backups = &backupsService{client: c} 270 | c.Databases = &databasesService{client: c} 271 | c.DatabaseBranches = &databaseBranchesService{client: c} 272 | c.DataImports = &dataImportsService{client: c} 273 | c.Organizations = &organizationsService{client: c} 274 | c.Passwords = &passwordsService{client: c} 275 | c.Regions = ®ionsService{client: c} 276 | c.DeployRequests = &deployRequestsService{client: c} 277 | c.ServiceTokens = &serviceTokenService{client: c} 278 | c.Keyspaces = &keyspacesService{client: c} 279 | c.Workflows = &workflowsService{client: c} 280 | 281 | return c, nil 282 | } 283 | 284 | // do makes an HTTP request and populates the given struct v from the response. 285 | func (c *Client) do(ctx context.Context, req *http.Request, v interface{}) error { 286 | req = req.WithContext(ctx) 287 | res, err := c.client.Do(req) 288 | if err != nil { 289 | return err 290 | } 291 | defer res.Body.Close() 292 | 293 | return c.handleResponse(ctx, res, v) 294 | } 295 | 296 | // handleResponse makes an HTTP request and populates the given struct v from 297 | // the response. This is meant for internal testing and shouldn't be used 298 | // directly. Instead please use `Client.do`. 299 | func (c *Client) handleResponse(ctx context.Context, res *http.Response, v interface{}) error { 300 | out, err := io.ReadAll(res.Body) 301 | if err != nil { 302 | return err 303 | } 304 | 305 | if res.StatusCode >= 400 { 306 | // errorResponse represents an error response from the API 307 | type errorResponse struct { 308 | Code string `json:"code"` 309 | Message string `json:"message"` 310 | } 311 | 312 | errorRes := &errorResponse{} 313 | err = json.Unmarshal(out, errorRes) 314 | if err != nil { 315 | var jsonErr *json.SyntaxError 316 | if errors.As(err, &jsonErr) { 317 | return &Error{ 318 | msg: "malformed error response body received", 319 | Code: ErrResponseMalformed, 320 | Meta: map[string]string{ 321 | "body": string(out), 322 | "err": jsonErr.Error(), 323 | "http_status": http.StatusText(res.StatusCode), 324 | }, 325 | } 326 | } 327 | return err 328 | } 329 | 330 | // json.Unmarshal doesn't return an error if the response 331 | // body has a different protocol then "ErrorResponse". We 332 | // check here to make sure that errorRes is populated. If 333 | // not, we return the full response back to the user, so 334 | // they can debug the issue. 335 | // TODO(fatih): fix the behavior on the API side 336 | if *errorRes == (errorResponse{}) { 337 | return &Error{ 338 | msg: "internal error, response body doesn't match error type signature", 339 | Code: ErrInternal, 340 | Meta: map[string]string{ 341 | "body": string(out), 342 | "http_status": http.StatusText(res.StatusCode), 343 | }, 344 | } 345 | } 346 | 347 | var errCode ErrorCode 348 | switch errorRes.Code { 349 | case "not_found": 350 | errCode = ErrNotFound 351 | case "unauthorized": 352 | errCode = ErrPermission 353 | case "invalid_params": 354 | errCode = ErrInvalid 355 | case "unprocessable": 356 | errCode = ErrRetry 357 | } 358 | 359 | return &Error{ 360 | msg: errorRes.Message, 361 | Code: errCode, 362 | } 363 | } 364 | 365 | // this means we don't care about unmarshaling the response body into v 366 | if v == nil || res.StatusCode == http.StatusNoContent { 367 | return nil 368 | } 369 | 370 | err = json.Unmarshal(out, &v) 371 | if err != nil { 372 | var jsonErr *json.SyntaxError 373 | if errors.As(err, &jsonErr) { 374 | return &Error{ 375 | msg: "malformed response body received", 376 | Code: ErrResponseMalformed, 377 | Meta: map[string]string{ 378 | "body": string(out), 379 | "http_status": http.StatusText(res.StatusCode), 380 | }, 381 | } 382 | } 383 | return err 384 | } 385 | 386 | return nil 387 | } 388 | 389 | func (c *Client) newRequest(method string, path string, body interface{}) (*http.Request, error) { 390 | u, err := c.baseURL.Parse(path) 391 | if err != nil { 392 | return nil, err 393 | } 394 | 395 | var req *http.Request 396 | switch method { 397 | case http.MethodGet: 398 | req, err = http.NewRequest(method, u.String(), nil) 399 | if err != nil { 400 | return nil, err 401 | } 402 | default: 403 | buf := new(bytes.Buffer) 404 | if body != nil { 405 | err = json.NewEncoder(buf).Encode(body) 406 | if err != nil { 407 | return nil, err 408 | } 409 | } 410 | 411 | req, err = http.NewRequest(method, u.String(), buf) 412 | if err != nil { 413 | return nil, err 414 | } 415 | 416 | req.Header.Set("Content-Type", jsonMediaType) 417 | } 418 | 419 | req.Header.Set("Accept", jsonMediaType) 420 | req.Header.Set("User-Agent", c.UserAgent) 421 | 422 | for k, v := range c.headers { 423 | req.Header.Set(k, v) 424 | } 425 | 426 | return req, nil 427 | } 428 | 429 | type serviceTokenTransport struct { 430 | rt http.RoundTripper 431 | token string 432 | tokenName string 433 | } 434 | 435 | func (t *serviceTokenTransport) RoundTrip(req *http.Request) (*http.Response, error) { 436 | req.Header.Add("Authorization", t.tokenName+":"+t.token) 437 | return t.rt.RoundTrip(req) 438 | } 439 | 440 | // Error represents common errors originating from the Client. 441 | type Error struct { 442 | // msg contains the human readable string 443 | msg string 444 | 445 | // Code specifies the error code. i.e; NotFound, RateLimited, etc... 446 | Code ErrorCode 447 | 448 | // Meta contains additional information depending on the error code. As an 449 | // example, if the Code is "ErrResponseMalformed", the map will be: ["body"] 450 | // = "body of the response" 451 | Meta map[string]string 452 | } 453 | 454 | // Error returns the string representation of the error. 455 | func (e *Error) Error() string { return e.msg } 456 | 457 | // CursorPaginatedResponse provides a generic means of wrapping a paginated 458 | // response. 459 | type CursorPaginatedResponse[T any] struct { 460 | Data []T `json:"data"` 461 | HasNext bool `json:"has_next"` 462 | HasPrev bool `json:"has_prev"` 463 | // CursorStart is the ending cursor of the previous page. 464 | CursorStart *string `json:"cursor_start"` 465 | 466 | // CursorEnd is the starting cursor of the next page. 467 | CursorEnd *string `json:"cursor_end"` 468 | } 469 | -------------------------------------------------------------------------------- /planetscale/client_test.go: -------------------------------------------------------------------------------- 1 | package planetscale 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "net/http/httptest" 7 | "testing" 8 | 9 | qt "github.com/frankban/quicktest" 10 | ) 11 | 12 | func TestDo(t *testing.T) { 13 | tests := []struct { 14 | desc string 15 | response string 16 | statusCode int 17 | method string 18 | expectedError error 19 | clientOptions []ClientOption 20 | wantHeaders map[string]string 21 | body interface{} 22 | v interface{} 23 | want interface{} 24 | }{ 25 | { 26 | desc: "returns an HTTP response and no error for 2xx responses", 27 | statusCode: http.StatusOK, 28 | response: `{}`, 29 | method: http.MethodGet, 30 | wantHeaders: map[string]string{ 31 | "User-Agent": "planetscale-go/unknown", 32 | }, 33 | }, 34 | { 35 | desc: "sets a custom header with the request option", 36 | statusCode: http.StatusOK, 37 | response: `{}`, 38 | method: http.MethodGet, 39 | clientOptions: []ClientOption{WithUserAgent("test-user-agent"), WithRequestHeaders(map[string]string{"Test-Header": "test-value"})}, 40 | wantHeaders: map[string]string{ 41 | "Test-Header": "test-value", 42 | "User-Agent": "test-user-agent planetscale-go/unknown", 43 | }, 44 | }, 45 | { 46 | desc: "returns ErrorResponse for 4xx errors", 47 | statusCode: http.StatusNotFound, 48 | method: http.MethodGet, 49 | response: `{ 50 | "code": "not_found", 51 | "message": "Not Found" 52 | }`, 53 | expectedError: &Error{ 54 | msg: "Not Found", 55 | Code: ErrNotFound, 56 | }, 57 | }, 58 | { 59 | desc: "returns ErrorResponse for 5xx errors", 60 | statusCode: http.StatusInternalServerError, 61 | method: http.MethodGet, 62 | response: `{}`, 63 | expectedError: &Error{ 64 | msg: "internal error, response body doesn't match error type signature", 65 | Code: ErrInternal, 66 | }, 67 | }, 68 | { 69 | desc: "returns an HTTP response 200 when posting a request", 70 | statusCode: http.StatusOK, 71 | response: ` 72 | { 73 | "id": "509", 74 | "type": "database", 75 | "name": "foo-bar", 76 | "notes": "" 77 | }`, 78 | body: &Database{ 79 | Name: "foo-bar", 80 | }, 81 | v: &Database{}, 82 | want: &Database{ 83 | Name: "foo-bar", 84 | }, 85 | }, 86 | { 87 | desc: "returns an HTTP response 204 when deleting a request", 88 | statusCode: http.StatusNoContent, 89 | method: http.MethodDelete, 90 | response: "", 91 | body: nil, 92 | v: &Database{}, 93 | want: nil, 94 | }, 95 | { 96 | desc: "returns an non-204 HTTP response when deleting a request", 97 | statusCode: http.StatusAccepted, 98 | method: http.MethodDelete, 99 | response: `{ 100 | "id": "test" 101 | }`, 102 | body: nil, 103 | v: &DatabaseDeletionRequest{}, 104 | want: &DatabaseDeletionRequest{ 105 | ID: "test", 106 | }, 107 | }, 108 | } 109 | 110 | for _, tt := range tests { 111 | t.Run(tt.desc, func(t *testing.T) { 112 | ctx := context.Background() 113 | c := qt.New(t) 114 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 115 | w.WriteHeader(tt.statusCode) 116 | 117 | if tt.wantHeaders != nil { 118 | for key, value := range tt.wantHeaders { 119 | c.Assert(r.Header.Get(key), qt.Equals, value) 120 | } 121 | } 122 | 123 | res := []byte(tt.response) 124 | if tt.response == "" { 125 | res = nil 126 | } 127 | _, err := w.Write(res) 128 | if err != nil { 129 | t.Fatal(err) 130 | } 131 | })) 132 | t.Cleanup(ts.Close) 133 | 134 | opts := append(tt.clientOptions, WithBaseURL(ts.URL)) 135 | client, err := NewClient(opts...) 136 | if err != nil { 137 | t.Fatal(err) 138 | } 139 | 140 | req, err := client.newRequest(tt.method, "/api-endpoint", tt.body) 141 | if err != nil { 142 | t.Fatal(err) 143 | } 144 | 145 | res, err := client.client.Do(req) 146 | c.Assert(err, qt.IsNil) 147 | defer res.Body.Close() 148 | 149 | err = client.handleResponse(ctx, res, &tt.v) 150 | if err != nil { 151 | if tt.expectedError != nil { 152 | c.Assert(tt.expectedError.Error(), qt.Equals, err.Error()) 153 | } 154 | } 155 | 156 | c.Assert(res, qt.Not(qt.IsNil)) 157 | c.Assert(res.StatusCode, qt.Equals, tt.statusCode) 158 | 159 | if tt.v != nil && tt.want != nil { 160 | c.Assert(tt.want, qt.DeepEquals, tt.v) 161 | } 162 | }) 163 | } 164 | } 165 | 166 | func Pointer[K any](val K) *K { 167 | return &val 168 | } 169 | -------------------------------------------------------------------------------- /planetscale/databases.go: -------------------------------------------------------------------------------- 1 | package planetscale 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | "time" 8 | ) 9 | 10 | // CreateDatabaseRequest encapsulates the request for creating a new database. 11 | type CreateDatabaseRequest struct { 12 | Organization string 13 | Name string `json:"name"` 14 | Notes string `json:"notes,omitempty"` 15 | Region string `json:"region,omitempty"` 16 | ClusterSize string `json:"cluster_size"` 17 | } 18 | 19 | // DatabaseRequest encapsulates the request for getting a single database. 20 | type GetDatabaseRequest struct { 21 | Organization string 22 | Database string 23 | } 24 | 25 | // ListDatabasesRequest encapsulates the request for listing all databases in an 26 | // organization. 27 | type ListDatabasesRequest struct { 28 | Organization string 29 | } 30 | 31 | // DeleteDatabaseRequest encapsulates the request for deleting a database from 32 | // an organization. 33 | type DeleteDatabaseRequest struct { 34 | Organization string 35 | Database string 36 | } 37 | 38 | // DatabaseService is an interface for communicating with the PlanetScale 39 | // Databases API endpoint. 40 | type DatabasesService interface { 41 | Create(context.Context, *CreateDatabaseRequest) (*Database, error) 42 | Get(context.Context, *GetDatabaseRequest) (*Database, error) 43 | List(context.Context, *ListDatabasesRequest, ...ListOption) ([]*Database, error) 44 | Delete(context.Context, *DeleteDatabaseRequest) (*DatabaseDeletionRequest, error) 45 | } 46 | 47 | // DatabaseDeletionRequest encapsulates the request for deleting a database from 48 | // an organization. 49 | type DatabaseDeletionRequest struct { 50 | ID string `json:"id"` 51 | Actor Actor `json:"actor"` 52 | } 53 | 54 | // DatabaseState represents the state of a database 55 | type DatabaseState string 56 | 57 | const ( 58 | DatabasePending DatabaseState = "pending" 59 | DatabaseImporting DatabaseState = "importing" 60 | DatabaseAwakening DatabaseState = "awakening" 61 | DatabaseSleepInProgress DatabaseState = "sleep_in_progress" 62 | DatabaseSleeping DatabaseState = "sleeping" 63 | DatabaseReady DatabaseState = "ready" 64 | ) 65 | 66 | // Database represents a PlanetScale database 67 | type Database struct { 68 | Name string `json:"name"` 69 | Notes string `json:"notes"` 70 | Region Region `json:"region"` 71 | State DatabaseState `json:"state"` 72 | HtmlURL string `json:"html_url"` 73 | CreatedAt time.Time `json:"created_at"` 74 | UpdatedAt time.Time `json:"updated_at"` 75 | } 76 | 77 | // Database represents a list of PlanetScale databases 78 | type databasesResponse struct { 79 | Databases []*Database `json:"data"` 80 | } 81 | 82 | type databasesService struct { 83 | client *Client 84 | } 85 | 86 | var _ DatabasesService = &databasesService{} 87 | 88 | func NewDatabasesService(client *Client) *databasesService { 89 | return &databasesService{ 90 | client: client, 91 | } 92 | } 93 | 94 | func (ds *databasesService) List(ctx context.Context, listReq *ListDatabasesRequest, opts ...ListOption) ([]*Database, error) { 95 | path := databasesAPIPath(listReq.Organization) 96 | 97 | defaultOpts := defaultListOptions(WithPerPage(100)) 98 | for _, opt := range opts { 99 | err := opt(defaultOpts) 100 | if err != nil { 101 | return nil, err 102 | } 103 | } 104 | 105 | if vals := defaultOpts.URLValues.Encode(); vals != "" { 106 | path += "?" + vals 107 | } 108 | 109 | req, err := ds.client.newRequest(http.MethodGet, path, nil) 110 | if err != nil { 111 | return nil, fmt.Errorf("error creating http request: %w", err) 112 | } 113 | 114 | dbResponse := databasesResponse{} 115 | err = ds.client.do(ctx, req, &dbResponse) 116 | if err != nil { 117 | return nil, err 118 | } 119 | 120 | return dbResponse.Databases, nil 121 | } 122 | 123 | func (ds *databasesService) Create(ctx context.Context, createReq *CreateDatabaseRequest) (*Database, error) { 124 | req, err := ds.client.newRequest(http.MethodPost, databasesAPIPath(createReq.Organization), createReq) 125 | if err != nil { 126 | return nil, fmt.Errorf("error creating request for create database: %w", err) 127 | } 128 | 129 | db := &Database{} 130 | err = ds.client.do(ctx, req, &db) 131 | if err != nil { 132 | return nil, err 133 | } 134 | 135 | return db, nil 136 | } 137 | 138 | func (ds *databasesService) Get(ctx context.Context, getReq *GetDatabaseRequest) (*Database, error) { 139 | path := fmt.Sprintf("%s/%s", databasesAPIPath(getReq.Organization), getReq.Database) 140 | req, err := ds.client.newRequest(http.MethodGet, path, nil) 141 | if err != nil { 142 | return nil, fmt.Errorf("error creating request for get database: %w", err) 143 | } 144 | 145 | db := &Database{} 146 | err = ds.client.do(ctx, req, &db) 147 | if err != nil { 148 | return nil, err 149 | } 150 | 151 | return db, nil 152 | } 153 | 154 | func (ds *databasesService) Delete(ctx context.Context, deleteReq *DeleteDatabaseRequest) (*DatabaseDeletionRequest, error) { 155 | path := fmt.Sprintf("%s/%s", databasesAPIPath(deleteReq.Organization), deleteReq.Database) 156 | req, err := ds.client.newRequest(http.MethodDelete, path, nil) 157 | if err != nil { 158 | return nil, fmt.Errorf("error creating request for delete database: %w", err) 159 | } 160 | 161 | var dbr *DatabaseDeletionRequest 162 | err = ds.client.do(ctx, req, &dbr) 163 | if err != nil { 164 | return nil, err 165 | } 166 | 167 | return dbr, nil 168 | } 169 | 170 | func databasesAPIPath(org string) string { 171 | return fmt.Sprintf("v1/organizations/%s/databases", org) 172 | } 173 | -------------------------------------------------------------------------------- /planetscale/databases_test.go: -------------------------------------------------------------------------------- 1 | package planetscale 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "net/http/httptest" 7 | "testing" 8 | "time" 9 | 10 | qt "github.com/frankban/quicktest" 11 | ) 12 | 13 | const ( 14 | testOrg = "my-org" 15 | testDatabase = "planetscale-go-test-db" 16 | ) 17 | 18 | func TestDatabases_Create(t *testing.T) { 19 | c := qt.New(t) 20 | 21 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 22 | w.WriteHeader(200) 23 | out := `{"id":"planetscale-go-test-db","type":"database","name":"planetscale-go-test-db","notes":"This is a test DB created from the planetscale-go API library","created_at":"2021-01-14T10:19:23.000Z","updated_at":"2021-01-14T10:19:23.000Z", "region": { "slug": "us-west", "display_name": "US West" },"state":"ready"}` 24 | _, err := w.Write([]byte(out)) 25 | c.Assert(err, qt.IsNil) 26 | })) 27 | 28 | client, err := NewClient(WithBaseURL(ts.URL)) 29 | c.Assert(err, qt.IsNil) 30 | 31 | ctx := context.Background() 32 | org := "my-org" 33 | name := "planetscale-go-test-db" 34 | notes := "This is a test DB created from the planetscale-go API library" 35 | 36 | db, err := client.Databases.Create(ctx, &CreateDatabaseRequest{ 37 | Organization: org, 38 | Region: "us-west", 39 | Name: name, 40 | Notes: notes, 41 | }) 42 | 43 | want := &Database{ 44 | Name: name, 45 | Notes: notes, 46 | State: DatabaseReady, 47 | Region: Region{ 48 | Slug: "us-west", 49 | Name: "US West", 50 | }, 51 | CreatedAt: time.Date(2021, time.January, 14, 10, 19, 23, 0, time.UTC), 52 | UpdatedAt: time.Date(2021, time.January, 14, 10, 19, 23, 0, time.UTC), 53 | } 54 | 55 | c.Assert(err, qt.IsNil) 56 | c.Assert(db, qt.DeepEquals, want) 57 | } 58 | 59 | func TestDatabases_Get(t *testing.T) { 60 | c := qt.New(t) 61 | 62 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 63 | w.WriteHeader(200) 64 | out := `{"id":"planetscale-go-test-db","type":"database","name":"planetscale-go-test-db","notes":"This is a test DB created from the planetscale-go API library","created_at":"2021-01-14T10:19:23.000Z","updated_at":"2021-01-14T10:19:23.000Z"}` 65 | _, err := w.Write([]byte(out)) 66 | c.Assert(err, qt.IsNil) 67 | })) 68 | 69 | client, err := NewClient(WithBaseURL(ts.URL)) 70 | c.Assert(err, qt.IsNil) 71 | 72 | ctx := context.Background() 73 | org := "my-org" 74 | name := "planetscale-go-test-db" 75 | notes := "This is a test DB created from the planetscale-go API library" 76 | 77 | db, err := client.Databases.Get(ctx, &GetDatabaseRequest{ 78 | Organization: org, 79 | Database: name, 80 | }) 81 | 82 | want := &Database{ 83 | Name: name, 84 | Notes: notes, 85 | CreatedAt: time.Date(2021, time.January, 14, 10, 19, 23, 0, time.UTC), 86 | UpdatedAt: time.Date(2021, time.January, 14, 10, 19, 23, 0, time.UTC), 87 | } 88 | 89 | c.Assert(err, qt.IsNil) 90 | c.Assert(db, qt.DeepEquals, want) 91 | } 92 | 93 | func TestDatabases_List(t *testing.T) { 94 | c := qt.New(t) 95 | 96 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 97 | w.WriteHeader(200) 98 | out := `{"data":[{"id":"planetscale-go-test-db","type":"database", "name":"planetscale-go-test-db","notes":"This is a test DB created from the planetscale-go API library","created_at":"2021-01-14T10:19:23.000Z","updated_at":"2021-01-14T10:19:23.000Z"}]}` 99 | _, err := w.Write([]byte(out)) 100 | c.Assert(err, qt.IsNil) 101 | })) 102 | 103 | client, err := NewClient(WithBaseURL(ts.URL)) 104 | c.Assert(err, qt.IsNil) 105 | 106 | ctx := context.Background() 107 | org := "my-org" 108 | name := "planetscale-go-test-db" 109 | notes := "This is a test DB created from the planetscale-go API library" 110 | 111 | db, err := client.Databases.List(ctx, &ListDatabasesRequest{ 112 | Organization: org, 113 | }) 114 | 115 | want := []*Database{{ 116 | Name: name, 117 | Notes: notes, 118 | CreatedAt: time.Date(2021, time.January, 14, 10, 19, 23, 0, time.UTC), 119 | UpdatedAt: time.Date(2021, time.January, 14, 10, 19, 23, 0, time.UTC), 120 | }} 121 | 122 | c.Assert(err, qt.IsNil) 123 | c.Assert(db, qt.DeepEquals, want) 124 | } 125 | 126 | func TestDatabases_ListWithOptions(t *testing.T) { 127 | c := qt.New(t) 128 | 129 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 130 | w.WriteHeader(200) 131 | out := `{"data":[{"id":"planetscale-go-test-db","type":"database", "name":"planetscale-go-test-db","notes":"This is a test DB created from the planetscale-go API library","created_at":"2021-01-14T10:19:23.000Z","updated_at":"2021-01-14T10:19:23.000Z"}]}` 132 | _, err := w.Write([]byte(out)) 133 | c.Assert(err, qt.IsNil) 134 | c.Assert(r.URL.Query().Get("page"), qt.Equals, "2") 135 | c.Assert(r.URL.Query().Get("per_page"), qt.Equals, "100") 136 | })) 137 | 138 | client, err := NewClient(WithBaseURL(ts.URL)) 139 | c.Assert(err, qt.IsNil) 140 | 141 | ctx := context.Background() 142 | org := "my-org" 143 | name := "planetscale-go-test-db" 144 | notes := "This is a test DB created from the planetscale-go API library" 145 | 146 | db, err := client.Databases.List(ctx, &ListDatabasesRequest{ 147 | Organization: org, 148 | }, WithPage(2)) 149 | 150 | want := []*Database{{ 151 | Name: name, 152 | Notes: notes, 153 | CreatedAt: time.Date(2021, time.January, 14, 10, 19, 23, 0, time.UTC), 154 | UpdatedAt: time.Date(2021, time.January, 14, 10, 19, 23, 0, time.UTC), 155 | }} 156 | 157 | c.Assert(err, qt.IsNil) 158 | c.Assert(db, qt.DeepEquals, want) 159 | } 160 | 161 | func TestDatabases_DeleteNoContent(t *testing.T) { 162 | c := qt.New(t) 163 | 164 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 165 | w.WriteHeader(http.StatusNoContent) 166 | _, err := w.Write(nil) 167 | c.Assert(err, qt.IsNil) 168 | })) 169 | 170 | client, err := NewClient(WithBaseURL(ts.URL)) 171 | c.Assert(err, qt.IsNil) 172 | 173 | ctx := context.Background() 174 | org := "my-org" 175 | 176 | dbr, err := client.Databases.Delete(ctx, &DeleteDatabaseRequest{ 177 | Organization: org, 178 | Database: "planetscale-go-test-db", 179 | }) 180 | 181 | c.Assert(err, qt.IsNil) 182 | c.Assert(dbr, qt.IsNil) 183 | } 184 | 185 | func TestDatabases_DeleteAccepted(t *testing.T) { 186 | c := qt.New(t) 187 | 188 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 189 | w.WriteHeader(http.StatusAccepted) 190 | out := `{"id": "planetscale-go-test-db"}` 191 | _, err := w.Write([]byte(out)) 192 | c.Assert(err, qt.IsNil) 193 | })) 194 | 195 | client, err := NewClient(WithBaseURL(ts.URL)) 196 | c.Assert(err, qt.IsNil) 197 | 198 | ctx := context.Background() 199 | org := "my-org" 200 | 201 | dbr, err := client.Databases.Delete(ctx, &DeleteDatabaseRequest{ 202 | Organization: org, 203 | Database: "planetscale-go-test-db", 204 | }) 205 | 206 | want := &DatabaseDeletionRequest{ 207 | ID: "planetscale-go-test-db", 208 | } 209 | 210 | c.Assert(err, qt.IsNil) 211 | c.Assert(dbr, qt.DeepEquals, want) 212 | } 213 | 214 | func TestDatabases_List_malformed_response(t *testing.T) { 215 | c := qt.New(t) 216 | 217 | malformedBody := `400 Bad Request 218 |
nginx
` 219 | 220 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 221 | w.WriteHeader(400) 222 | _, err := w.Write([]byte(malformedBody)) 223 | c.Assert(err, qt.IsNil) 224 | })) 225 | 226 | client, err := NewClient(WithBaseURL(ts.URL)) 227 | c.Assert(err, qt.IsNil) 228 | 229 | ctx := context.Background() 230 | org := "my-org" 231 | 232 | _, err = client.Databases.List(ctx, &ListDatabasesRequest{ 233 | Organization: org, 234 | }) 235 | 236 | c.Assert(err, qt.Not(qt.IsNil)) 237 | c.Assert(err, qt.ErrorMatches, `malformed error response body received`) 238 | } 239 | 240 | func TestDatabases_Empty(t *testing.T) { 241 | c := qt.New(t) 242 | 243 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 244 | w.WriteHeader(200) 245 | out := `{"data":[]}` 246 | _, err := w.Write([]byte(out)) 247 | c.Assert(err, qt.IsNil) 248 | })) 249 | 250 | client, err := NewClient(WithBaseURL(ts.URL)) 251 | c.Assert(err, qt.IsNil) 252 | 253 | ctx := context.Background() 254 | org := "my-org" 255 | 256 | db, err := client.Databases.List(ctx, &ListDatabasesRequest{ 257 | Organization: org, 258 | }) 259 | 260 | c.Assert(err, qt.IsNil) 261 | c.Assert(db, qt.HasLen, 0) 262 | } 263 | -------------------------------------------------------------------------------- /planetscale/deploy_requests_test.go: -------------------------------------------------------------------------------- 1 | package planetscale 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "net/http" 7 | "net/http/httptest" 8 | "net/url" 9 | "testing" 10 | "time" 11 | 12 | qt "github.com/frankban/quicktest" 13 | ) 14 | 15 | func TestDeployRequests_Get(t *testing.T) { 16 | c := qt.New(t) 17 | 18 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 19 | w.WriteHeader(200) 20 | out := `{"id": "test-deploy-request-id", "branch": "development", "into_branch": "some-branch", "notes": "", "created_at": "2021-01-14T10:19:23.000Z", "updated_at": "2021-01-14T10:19:23.000Z", "closed_at": null, "number": 1337}` 21 | _, err := w.Write([]byte(out)) 22 | c.Assert(err, qt.IsNil) 23 | })) 24 | 25 | client, err := NewClient(WithBaseURL(ts.URL)) 26 | c.Assert(err, qt.IsNil) 27 | 28 | ctx := context.Background() 29 | 30 | dr, err := client.DeployRequests.Get(ctx, &GetDeployRequestRequest{ 31 | Organization: "test-organization", 32 | Database: "test-database", 33 | Number: 1337, 34 | }) 35 | 36 | testTime := time.Date(2021, time.January, 14, 10, 19, 23, 0, time.UTC) 37 | 38 | want := &DeployRequest{ 39 | ID: "test-deploy-request-id", 40 | Number: 1337, 41 | Branch: "development", 42 | IntoBranch: "some-branch", 43 | Notes: "", 44 | CreatedAt: testTime, 45 | UpdatedAt: testTime, 46 | ClosedAt: nil, 47 | } 48 | 49 | c.Assert(err, qt.IsNil) 50 | c.Assert(dr, qt.DeepEquals, want) 51 | } 52 | 53 | func TestDeployRequests_Deploy(t *testing.T) { 54 | c := qt.New(t) 55 | 56 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 57 | w.WriteHeader(200) 58 | out := `{"id": "test-deploy-request-id", "branch": "development", "into_branch": "some-branch", "notes": "", "created_at": "2021-01-14T10:19:23.000Z", "updated_at": "2021-01-14T10:19:23.000Z", "closed_at": "2021-01-14T10:19:23.000Z", "deployment": { "state": "queued"}, "number": 1337}` 59 | _, err := w.Write([]byte(out)) 60 | c.Assert(err, qt.IsNil) 61 | })) 62 | 63 | client, err := NewClient(WithBaseURL(ts.URL)) 64 | c.Assert(err, qt.IsNil) 65 | 66 | ctx := context.Background() 67 | 68 | dr, err := client.DeployRequests.Deploy(ctx, &PerformDeployRequest{ 69 | Organization: "test-organization", 70 | Database: "test-database", 71 | Number: 1337, 72 | }) 73 | 74 | testTime := time.Date(2021, time.January, 14, 10, 19, 23, 0, time.UTC) 75 | 76 | want := &DeployRequest{ 77 | ID: "test-deploy-request-id", 78 | Branch: "development", 79 | IntoBranch: "some-branch", 80 | Number: 1337, 81 | Deployment: &Deployment{ 82 | State: "queued", 83 | }, 84 | Notes: "", 85 | CreatedAt: testTime, 86 | UpdatedAt: testTime, 87 | ClosedAt: &testTime, 88 | } 89 | 90 | c.Assert(err, qt.IsNil) 91 | c.Assert(dr, qt.DeepEquals, want) 92 | } 93 | 94 | func TestDeployRequests_InstantDeploy(t *testing.T) { 95 | c := qt.New(t) 96 | 97 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 98 | var request struct { 99 | InstantDDL bool `json:"instant_ddl"` 100 | } 101 | err := json.NewDecoder(r.Body).Decode(&request) 102 | c.Assert(err, qt.IsNil) 103 | c.Assert(request.InstantDDL, qt.Equals, true) 104 | w.WriteHeader(200) 105 | out := `{"id": "test-deploy-request-id", "branch": "development", "into_branch": "some-branch", "notes": "", "created_at": "2021-01-14T10:19:23.000Z", "updated_at": "2021-01-14T10:19:23.000Z", "closed_at": "2021-01-14T10:19:23.000Z", "deployment": { "state": "queued", "instant_ddl": true }, "number": 1337}` 106 | _, err = w.Write([]byte(out)) 107 | c.Assert(err, qt.IsNil) 108 | })) 109 | 110 | client, err := NewClient(WithBaseURL(ts.URL)) 111 | c.Assert(err, qt.IsNil) 112 | 113 | ctx := context.Background() 114 | 115 | dr, err := client.DeployRequests.Deploy(ctx, &PerformDeployRequest{ 116 | Organization: "test-organization", 117 | Database: "test-database", 118 | Number: 1337, 119 | InstantDDL: true, 120 | }) 121 | 122 | testTime := time.Date(2021, time.January, 14, 10, 19, 23, 0, time.UTC) 123 | 124 | want := &DeployRequest{ 125 | ID: "test-deploy-request-id", 126 | Branch: "development", 127 | IntoBranch: "some-branch", 128 | Number: 1337, 129 | Deployment: &Deployment{ 130 | State: "queued", 131 | InstantDDL: true, 132 | }, 133 | Notes: "", 134 | CreatedAt: testTime, 135 | UpdatedAt: testTime, 136 | ClosedAt: &testTime, 137 | } 138 | 139 | c.Assert(err, qt.IsNil) 140 | c.Assert(dr, qt.DeepEquals, want) 141 | } 142 | 143 | func TestDeployRequests_CancelDeploy(t *testing.T) { 144 | c := qt.New(t) 145 | 146 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 147 | w.WriteHeader(200) 148 | out := `{"id": "test-deploy-request-id", "branch": "development", "into_branch": "some-branch", "notes": "", "created_at": "2021-01-14T10:19:23.000Z", "updated_at": "2021-01-14T10:19:23.000Z", "closed_at": "2021-01-14T10:19:23.000Z", "deployment": { "state": "pending" }, "number": 1337}` 149 | _, err := w.Write([]byte(out)) 150 | c.Assert(err, qt.IsNil) 151 | })) 152 | 153 | client, err := NewClient(WithBaseURL(ts.URL)) 154 | c.Assert(err, qt.IsNil) 155 | 156 | ctx := context.Background() 157 | 158 | dr, err := client.DeployRequests.CancelDeploy(ctx, &CancelDeployRequestRequest{ 159 | Organization: "test-organization", 160 | Database: "test-database", 161 | Number: 1337, 162 | }) 163 | 164 | testTime := time.Date(2021, time.January, 14, 10, 19, 23, 0, time.UTC) 165 | 166 | want := &DeployRequest{ 167 | ID: "test-deploy-request-id", 168 | Branch: "development", 169 | Deployment: &Deployment{ 170 | State: "pending", 171 | }, 172 | IntoBranch: "some-branch", 173 | Number: 1337, 174 | Notes: "", 175 | CreatedAt: testTime, 176 | UpdatedAt: testTime, 177 | ClosedAt: &testTime, 178 | } 179 | 180 | c.Assert(err, qt.IsNil) 181 | c.Assert(dr, qt.DeepEquals, want) 182 | } 183 | 184 | func TestDeployRequests_Close(t *testing.T) { 185 | c := qt.New(t) 186 | 187 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 188 | w.WriteHeader(200) 189 | out := `{"id": "test-deploy-request-id", "branch": "development", "into_branch": "some-branch", "notes": "", "created_at": "2021-01-14T10:19:23.000Z", "updated_at": "2021-01-14T10:19:23.000Z", "closed_at": "2021-01-14T10:19:23.000Z", "deployment": { "state": "pending" }, "number": 1337}` 190 | _, err := w.Write([]byte(out)) 191 | c.Assert(err, qt.IsNil) 192 | })) 193 | 194 | client, err := NewClient(WithBaseURL(ts.URL)) 195 | c.Assert(err, qt.IsNil) 196 | 197 | ctx := context.Background() 198 | 199 | dr, err := client.DeployRequests.CloseDeploy(ctx, &CloseDeployRequestRequest{ 200 | Organization: "test-organization", 201 | Database: "test-database", 202 | Number: 1337, 203 | }) 204 | 205 | testTime := time.Date(2021, time.January, 14, 10, 19, 23, 0, time.UTC) 206 | 207 | want := &DeployRequest{ 208 | ID: "test-deploy-request-id", 209 | Branch: "development", 210 | Deployment: &Deployment{ 211 | State: "pending", 212 | }, 213 | IntoBranch: "some-branch", 214 | Number: 1337, 215 | Notes: "", 216 | CreatedAt: testTime, 217 | UpdatedAt: testTime, 218 | ClosedAt: &testTime, 219 | } 220 | 221 | c.Assert(err, qt.IsNil) 222 | c.Assert(dr, qt.DeepEquals, want) 223 | } 224 | 225 | func TestDeployRequests_Create(t *testing.T) { 226 | c := qt.New(t) 227 | 228 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 229 | w.WriteHeader(200) 230 | out := `{"id": "test-deploy-request-id", "number": 1337, "branch": "development", "into_branch": "some-branch", "notes": "", "created_at": "2021-01-14T10:19:23.000Z", "updated_at": "2021-01-14T10:19:23.000Z", "closed_at": "2021-01-14T10:19:23.000Z"}` 231 | _, err := w.Write([]byte(out)) 232 | c.Assert(err, qt.IsNil) 233 | })) 234 | 235 | client, err := NewClient(WithBaseURL(ts.URL)) 236 | c.Assert(err, qt.IsNil) 237 | 238 | ctx := context.Background() 239 | 240 | requests, err := client.DeployRequests.Create(ctx, &CreateDeployRequestRequest{ 241 | Organization: testOrg, 242 | Database: testDatabase, 243 | Notes: "", 244 | AutoDeleteBranch: true, 245 | AutoCutover: false, 246 | }) 247 | 248 | testTime := time.Date(2021, time.January, 14, 10, 19, 23, 0, time.UTC) 249 | 250 | want := &DeployRequest{ 251 | ID: "test-deploy-request-id", 252 | Number: 1337, 253 | Branch: "development", 254 | IntoBranch: "some-branch", 255 | Notes: "", 256 | CreatedAt: testTime, 257 | UpdatedAt: testTime, 258 | ClosedAt: &testTime, 259 | } 260 | 261 | c.Assert(err, qt.IsNil) 262 | c.Assert(requests, qt.DeepEquals, want) 263 | } 264 | 265 | func TestDeployRequests_Review(t *testing.T) { 266 | c := qt.New(t) 267 | 268 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 269 | w.WriteHeader(200) 270 | out := `{"id": "test-review-id","type": "DeployRequestReview","body": "test body","html_body": "","state": "approved","created_at": "2021-01-14T10:19:23.000Z","updated_at": "2021-01-14T10:19:23.000Z"}` 271 | _, err := w.Write([]byte(out)) 272 | c.Assert(err, qt.IsNil) 273 | })) 274 | 275 | client, err := NewClient(WithBaseURL(ts.URL)) 276 | c.Assert(err, qt.IsNil) 277 | 278 | ctx := context.Background() 279 | 280 | requests, err := client.DeployRequests.CreateReview(ctx, &ReviewDeployRequestRequest{ 281 | Organization: testOrg, 282 | Database: testDatabase, 283 | CommentText: "test body", 284 | ReviewAction: ReviewApprove, 285 | }) 286 | 287 | testTime := time.Date(2021, time.January, 14, 10, 19, 23, 0, time.UTC) 288 | 289 | want := &DeployRequestReview{ 290 | ID: "test-review-id", 291 | Body: "test body", 292 | State: "approved", 293 | CreatedAt: testTime, 294 | UpdatedAt: testTime, 295 | } 296 | 297 | c.Assert(err, qt.IsNil) 298 | c.Assert(requests, qt.DeepEquals, want) 299 | } 300 | 301 | func TestDeployRequests_List(t *testing.T) { 302 | c := qt.New(t) 303 | 304 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 305 | w.WriteHeader(200) 306 | out := `{"data": [{"id": "test-deploy-request-id", "branch": "development", "into_branch": "some-branch", "notes": "", "created_at": "2021-01-14T10:19:23.000Z", "updated_at": "2021-01-14T10:19:23.000Z", "closed_at": "2021-01-14T10:19:23.000Z"}]}` 307 | _, err := w.Write([]byte(out)) 308 | c.Assert(err, qt.IsNil) 309 | })) 310 | 311 | client, err := NewClient(WithBaseURL(ts.URL)) 312 | c.Assert(err, qt.IsNil) 313 | 314 | ctx := context.Background() 315 | 316 | requests, err := client.DeployRequests.List(ctx, &ListDeployRequestsRequest{ 317 | Organization: testOrg, 318 | Database: testDatabase, 319 | }) 320 | 321 | testTime := time.Date(2021, time.January, 14, 10, 19, 23, 0, time.UTC) 322 | 323 | want := []*DeployRequest{ 324 | { 325 | ID: "test-deploy-request-id", 326 | Branch: "development", 327 | IntoBranch: "some-branch", 328 | Notes: "", 329 | CreatedAt: testTime, 330 | UpdatedAt: testTime, 331 | ClosedAt: &testTime, 332 | }, 333 | } 334 | 335 | c.Assert(err, qt.IsNil) 336 | c.Assert(requests, qt.DeepEquals, want) 337 | } 338 | 339 | func TestDeployRequests_ListQueryParams(t *testing.T) { 340 | c := qt.New(t) 341 | 342 | var receivedQueryParams url.Values 343 | 344 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 345 | receivedQueryParams = r.URL.Query() 346 | 347 | w.WriteHeader(200) 348 | out := `{"data": [{"id": "test-deploy-request-id", "branch": "development", "into_branch": "some-branch", "notes": "", "created_at": "2021-01-14T10:19:23.000Z", "updated_at": "2021-01-14T10:19:23.000Z", "closed_at": "2021-01-14T10:19:23.000Z"}]}` 349 | _, err := w.Write([]byte(out)) 350 | c.Assert(err, qt.IsNil) 351 | })) 352 | 353 | client, err := NewClient(WithBaseURL(ts.URL)) 354 | c.Assert(err, qt.IsNil) 355 | 356 | ctx := context.Background() 357 | 358 | requests, err := client.DeployRequests.List(ctx, &ListDeployRequestsRequest{ 359 | Organization: testOrg, 360 | Database: testDatabase, 361 | State: "closed", 362 | Branch: "dev", 363 | IntoBranch: "main", 364 | }) 365 | 366 | testTime := time.Date(2021, time.January, 14, 10, 19, 23, 0, time.UTC) 367 | 368 | want := []*DeployRequest{ 369 | { 370 | ID: "test-deploy-request-id", 371 | Branch: "development", 372 | IntoBranch: "some-branch", 373 | Notes: "", 374 | CreatedAt: testTime, 375 | UpdatedAt: testTime, 376 | ClosedAt: &testTime, 377 | }, 378 | } 379 | 380 | c.Assert(err, qt.IsNil) 381 | c.Assert(requests, qt.DeepEquals, want) 382 | 383 | // Assert the expected query parameters 384 | c.Assert(receivedQueryParams.Get("state"), qt.Equals, "closed") 385 | c.Assert(receivedQueryParams.Get("branch"), qt.Equals, "dev") 386 | c.Assert(receivedQueryParams.Get("into_branch"), qt.Equals, "main") 387 | } 388 | 389 | func TestDeployRequests_SkipRevertDeploy(t *testing.T) { 390 | c := qt.New(t) 391 | 392 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 393 | w.WriteHeader(200) 394 | out := `{"id": "test-deploy-request-id", "branch": "development", "into_branch": "some-branch", "notes": "", "created_at": "2021-01-14T10:19:23.000Z", "updated_at": "2021-01-14T10:19:23.000Z", "closed_at": "2021-01-14T10:19:23.000Z", "deployment": { "state": "complete" }, "number": 1337}` 395 | _, err := w.Write([]byte(out)) 396 | c.Assert(err, qt.IsNil) 397 | })) 398 | 399 | client, err := NewClient(WithBaseURL(ts.URL)) 400 | c.Assert(err, qt.IsNil) 401 | 402 | ctx := context.Background() 403 | 404 | dr, err := client.DeployRequests.SkipRevertDeploy(ctx, &SkipRevertDeployRequestRequest{ 405 | Organization: "test-organization", 406 | Database: "test-database", 407 | Number: 1337, 408 | }) 409 | 410 | testTime := time.Date(2021, time.January, 14, 10, 19, 23, 0, time.UTC) 411 | 412 | want := &DeployRequest{ 413 | ID: "test-deploy-request-id", 414 | Branch: "development", 415 | Deployment: &Deployment{ 416 | State: "complete", 417 | }, 418 | IntoBranch: "some-branch", 419 | Number: 1337, 420 | Notes: "", 421 | CreatedAt: testTime, 422 | UpdatedAt: testTime, 423 | ClosedAt: &testTime, 424 | } 425 | 426 | c.Assert(err, qt.IsNil) 427 | c.Assert(dr, qt.DeepEquals, want) 428 | } 429 | 430 | func TestDeployRequests_RevertDeploy(t *testing.T) { 431 | c := qt.New(t) 432 | 433 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 434 | w.WriteHeader(200) 435 | out := `{"id": "test-deploy-request-id", "branch": "development", "into_branch": "some-branch", "notes": "", "created_at": "2021-01-14T10:19:23.000Z", "updated_at": "2021-01-14T10:19:23.000Z", "closed_at": "2021-01-14T10:19:23.000Z", "deployment": { "state": "complete_revert" }, "number": 1337}` 436 | _, err := w.Write([]byte(out)) 437 | c.Assert(err, qt.IsNil) 438 | })) 439 | 440 | client, err := NewClient(WithBaseURL(ts.URL)) 441 | c.Assert(err, qt.IsNil) 442 | 443 | ctx := context.Background() 444 | 445 | dr, err := client.DeployRequests.RevertDeploy(ctx, &RevertDeployRequestRequest{ 446 | Organization: "test-organization", 447 | Database: "test-database", 448 | Number: 1337, 449 | }) 450 | 451 | testTime := time.Date(2021, time.January, 14, 10, 19, 23, 0, time.UTC) 452 | 453 | want := &DeployRequest{ 454 | ID: "test-deploy-request-id", 455 | Branch: "development", 456 | Deployment: &Deployment{ 457 | State: "complete_revert", 458 | }, 459 | IntoBranch: "some-branch", 460 | Number: 1337, 461 | Notes: "", 462 | CreatedAt: testTime, 463 | UpdatedAt: testTime, 464 | ClosedAt: &testTime, 465 | } 466 | 467 | c.Assert(err, qt.IsNil) 468 | c.Assert(dr, qt.DeepEquals, want) 469 | } 470 | 471 | func TestDeployRequests_DeployOperations(t *testing.T) { 472 | c := qt.New(t) 473 | 474 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 475 | w.WriteHeader(200) 476 | out := `{ 477 | "type":"list", 478 | "current_page":1, 479 | "data":[ 480 | { 481 | "id":"test-operation-id", 482 | "type":"DeployOperation", 483 | "state":"pending", 484 | "keyspace_name":"treats", 485 | "table_name":"ice_creams", 486 | "operation_name":"CREATE", 487 | "created_at":"2021-01-14T10:19:23.000Z", 488 | "updated_at":"2021-01-14T10:19:23.000Z" 489 | } 490 | ] 491 | }` 492 | _, err := w.Write([]byte(out)) 493 | c.Assert(err, qt.IsNil) 494 | })) 495 | 496 | client, err := NewClient(WithBaseURL(ts.URL)) 497 | c.Assert(err, qt.IsNil) 498 | 499 | ctx := context.Background() 500 | 501 | do, err := client.DeployRequests.GetDeployOperations(ctx, &GetDeployOperationsRequest{ 502 | Organization: "test-organization", 503 | Database: "test-database", 504 | Number: 1337, 505 | }) 506 | 507 | testTime := time.Date(2021, time.January, 14, 10, 19, 23, 0, time.UTC) 508 | 509 | want := []*DeployOperation{{ 510 | ID: "test-operation-id", 511 | State: "pending", 512 | Table: "ice_creams", 513 | Keyspace: "treats", 514 | Operation: "CREATE", 515 | ETASeconds: 0, 516 | ProgressPercentage: 0, 517 | CreatedAt: testTime, 518 | UpdatedAt: testTime, 519 | }} 520 | c.Assert(err, qt.IsNil) 521 | c.Assert(do, qt.DeepEquals, want) 522 | } 523 | -------------------------------------------------------------------------------- /planetscale/imports.go: -------------------------------------------------------------------------------- 1 | package planetscale 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | "time" 8 | ) 9 | 10 | type BillingPlan int 11 | 12 | const ( 13 | HobbyPlan BillingPlan = iota 14 | ScalerProPlan 15 | ) 16 | 17 | func (bp BillingPlan) String() string { 18 | switch bp { 19 | case ScalerProPlan: 20 | return "scaler_pro" 21 | default: 22 | return "developer" 23 | } 24 | } 25 | 26 | var planToBillingPlanMap = map[string]BillingPlan{ 27 | "scaler_pro": ScalerProPlan, 28 | "developer": HobbyPlan, 29 | } 30 | 31 | type DataImportSource struct { 32 | HostName string `json:"hostname"` 33 | Database string `json:"schema_name"` 34 | Port int `json:"port"` 35 | SSLMode string `json:"ssl_mode"` 36 | SSLVerificationMode ExternalDataSourceSSLVerificationMode 37 | UserName string `json:"username"` 38 | Password string `json:"password"` 39 | SSLCA string `json:"ssl_ca"` 40 | SSLCertificate string `json:"ssl_cert"` 41 | SSLKey string `json:"ssl_key"` 42 | SSLServerName string `json:"ssl_server_name"` 43 | } 44 | 45 | type ExternalDataSourceSSLVerificationMode int 46 | 47 | const ( 48 | SSLModeDisabled ExternalDataSourceSSLVerificationMode = iota 49 | SSLModePreferred 50 | SSLModeRequired 51 | SSLModeVerifyCA 52 | SSLModeVerifyIdentity 53 | ) 54 | 55 | func (sm ExternalDataSourceSSLVerificationMode) String() string { 56 | switch sm { 57 | case SSLModeDisabled: 58 | return "disabled" 59 | case SSLModePreferred: 60 | return "preferred" 61 | case SSLModeRequired: 62 | return "required" 63 | case SSLModeVerifyCA: 64 | return "verify_ca" 65 | default: 66 | return "verify_identity" 67 | } 68 | } 69 | 70 | type DataImportState int 71 | 72 | const ( 73 | DataImportPreparingDataCopy DataImportState = iota 74 | DataImportPreparingDataCopyFailed 75 | DataImportCopyingData 76 | DataImportCopyingDataFailed 77 | DataImportSwitchTrafficPending 78 | DataImportSwitchTrafficRunning 79 | DataImportSwitchTrafficCompleted 80 | DataImportSwitchTrafficError 81 | DataImportReverseTrafficRunning 82 | DataImportReverseTrafficCompleted 83 | DataImportReverseTrafficError 84 | DataImportDetachExternalDatabaseRunning 85 | DataImportDetachExternalDatabaseError 86 | DataImportReady 87 | ) 88 | 89 | var stateToImportStateMap = map[string]DataImportState{ 90 | "prepare_data_copy_pending": DataImportPreparingDataCopy, 91 | "prepare_data_copy_error": DataImportPreparingDataCopyFailed, 92 | "data_copy_pending": DataImportCopyingData, 93 | "data_copy_error": DataImportCopyingDataFailed, 94 | "switch_traffic_workflow_pending": DataImportSwitchTrafficPending, 95 | "switch_traffic_workflow_running": DataImportSwitchTrafficRunning, 96 | "switch_traffic_workflow_error": DataImportSwitchTrafficError, 97 | "reverse_traffic_workflow_running": DataImportReverseTrafficRunning, 98 | "reverse_traffic_workflow_error": DataImportReverseTrafficError, 99 | "cleanup_workflow_pending": DataImportSwitchTrafficCompleted, 100 | "cleanup_workflow_running": DataImportDetachExternalDatabaseRunning, 101 | "cleanup_workflow_error": DataImportDetachExternalDatabaseError, 102 | "ready": DataImportReady, 103 | } 104 | 105 | var importStateToDescMap = map[DataImportState]string{ 106 | DataImportPreparingDataCopy: "Preparing to copy data from external database", 107 | DataImportPreparingDataCopyFailed: "Failed to copy data from external database", 108 | DataImportCopyingData: "Copying data from external database", 109 | DataImportCopyingDataFailed: "Failed to copy data from external database", 110 | DataImportSwitchTrafficPending: "PlanetScale database is running in replica mode", 111 | DataImportSwitchTrafficRunning: "Switching PlanetScale database to primary mode", 112 | DataImportSwitchTrafficError: "Failed to switching PlanetScale database to primary mode", 113 | DataImportReverseTrafficRunning: "Switching PlanetScale database to replica mode", 114 | DataImportReverseTrafficError: "Failed to switching PlanetScale database to replica mode", 115 | DataImportDetachExternalDatabaseRunning: "Detaching external database from PlanetScale database", 116 | DataImportDetachExternalDatabaseError: "Failed to detach external database from PlanetScale database", 117 | DataImportReady: "Import has completed and your PlanetScale Database is now ready", 118 | } 119 | 120 | func (d DataImportState) String() string { 121 | if val, ok := importStateToDescMap[d]; ok { 122 | return val 123 | } 124 | 125 | panic("unknown data import state") 126 | } 127 | 128 | type DataImport struct { 129 | ID string `json:"id"` 130 | ImportState DataImportState 131 | State string `json:"state"` 132 | Errors string `json:"import_check_errors"` 133 | StartedAt *time.Time `json:"started_at"` 134 | FinishedAt *time.Time `json:"finished_at"` 135 | DeletedAt *time.Time `json:"deleted_at"` 136 | ExternalDataSource DataImportSource `json:"data_source"` 137 | } 138 | 139 | func (di *DataImport) ParseState() { 140 | if val, ok := stateToImportStateMap[di.State]; ok { 141 | di.ImportState = val 142 | return 143 | } 144 | 145 | panic("unknown data import state " + di.State) 146 | } 147 | 148 | type TestDataImportSourceRequest struct { 149 | Organization string `json:"organization"` 150 | Database string `json:"database_name"` 151 | Connection DataImportSource `json:"connection"` 152 | } 153 | 154 | // DataSourceIncompatibilityError represents an error that occurs when the 155 | // source schema in an external database server is incompatible with PlanetScale. 156 | type DataSourceIncompatibilityError struct { 157 | LintError string `json:"lint_error"` 158 | Keyspace string `json:"keyspace_name"` 159 | Table string `json:"table_name"` 160 | SubjectType string `json:"subject_type"` 161 | ErrorDescription string `json:"error_description"` 162 | DocsUrl string `json:"docs_url"` 163 | } 164 | 165 | type UserShouldUpgradePlanError struct{} 166 | 167 | func (e UserShouldUpgradePlanError) Error() string { 168 | return "Importing databases over 5GB requires a paid plan. Log in to app.planetscale.com to upgrade." 169 | } 170 | 171 | type TestDataImportSourceResponse struct { 172 | CanConnect bool `json:"can_connect"` 173 | ShouldUpgradePlan bool `json:"should_upgrade"` 174 | SuggestedPlan string `json:"suggested_plan"` 175 | SuggestedBillingPlan BillingPlan 176 | ConnectError string `json:"error"` 177 | Errors []*DataSourceIncompatibilityError `json:"lint_errors"` 178 | MaxPoolSize int `json:"max_pool_size"` 179 | } 180 | 181 | type StartDataImportRequest struct { 182 | Organization string `json:"organization"` 183 | Database string `json:"database_name"` 184 | Connection DataImportSource `json:"connection"` 185 | Region string `json:"region"` 186 | Plan string `json:"plan"` 187 | MaxPoolSize int `json:"max_pool_size"` 188 | } 189 | 190 | type MakePlanetScalePrimaryRequest struct { 191 | Organization string 192 | Database string 193 | } 194 | 195 | type MakePlanetScaleReplicaRequest struct { 196 | Organization string 197 | Database string 198 | } 199 | 200 | type DetachExternalDatabaseRequest struct { 201 | Organization string 202 | Database string 203 | } 204 | 205 | type GetImportStatusRequest struct { 206 | Organization string 207 | Database string 208 | } 209 | 210 | type CancelDataImportRequest struct { 211 | Organization string 212 | Database string 213 | } 214 | 215 | // DataImportsService is an interface for communicating with the PlanetScale 216 | // Data Imports API endpoint. 217 | type DataImportsService interface { 218 | // TestDataImportSource checks if the external database that we're importing will be supported 219 | // by PlanetScale. It checks for ability to replicate binlogs, schema compatibility and other factors. 220 | TestDataImportSource(ctx context.Context, request *TestDataImportSourceRequest) (*TestDataImportSourceResponse, error) 221 | // StartDataImport spins up a downstream PlanetScale database in replica mode, with the 222 | // external database as a Primary and starts copying data from external to PlanetScale. 223 | StartDataImport(ctx context.Context, request *StartDataImportRequest) (*DataImport, error) 224 | // CancelDataImport halts all replication and data copy from external to PlanetScale 225 | // and deletes the PlanetScale database. 226 | CancelDataImport(ctx context.Context, request *CancelDataImportRequest) error 227 | // GetDataImportStatus gets the current status of a DataImport for a given database 228 | // Fails if the database is not importing any data. 229 | GetDataImportStatus(ctx context.Context, request *GetImportStatusRequest) (*DataImport, error) 230 | // MakePlanetScalePrimary makes the downstream PlanetScale database a Primary and the external database a Replica. 231 | MakePlanetScalePrimary(ctx context.Context, request *MakePlanetScalePrimaryRequest) (*DataImport, error) 232 | // MakePlanetScaleReplica makes the downstream PlanetScale database a Replica and the external database a Primary. 233 | MakePlanetScaleReplica(ctx context.Context, request *MakePlanetScaleReplicaRequest) (*DataImport, error) 234 | // DetachExternalDatabase detaches the external database from PlanetScale after a data import has finished 235 | // and PlanetScale is running as Primary. 236 | DetachExternalDatabase(ctx context.Context, request *DetachExternalDatabaseRequest) (*DataImport, error) 237 | } 238 | 239 | type dataImportsService struct { 240 | client *Client 241 | } 242 | 243 | // TestDataImportSource will check an external database for compatibility with PlanetScale 244 | func (d *dataImportsService) TestDataImportSource(ctx context.Context, request *TestDataImportSourceRequest) (*TestDataImportSourceResponse, error) { 245 | request.Connection.SSLMode = request.Connection.SSLVerificationMode.String() 246 | path := fmt.Sprintf("/v1/organizations/%s/data-imports/test-connection", request.Organization) 247 | req, err := d.client.newRequest(http.MethodPost, path, request) 248 | if err != nil { 249 | return nil, fmt.Errorf("error creating http request: %w", err) 250 | } 251 | 252 | resp := &TestDataImportSourceResponse{} 253 | if err := d.client.do(ctx, req, &resp); err != nil { 254 | return nil, err 255 | } 256 | 257 | if resp.ShouldUpgradePlan { 258 | return resp, UserShouldUpgradePlanError{} 259 | } 260 | 261 | resp.SuggestedBillingPlan = planToBillingPlanMap[resp.SuggestedPlan] 262 | return resp, nil 263 | } 264 | 265 | func (d *dataImportsService) StartDataImport(ctx context.Context, request *StartDataImportRequest) (*DataImport, error) { 266 | request.Connection.SSLMode = request.Connection.SSLVerificationMode.String() 267 | path := fmt.Sprintf("/v1/organizations/%s/data-imports/new", request.Organization) 268 | req, err := d.client.newRequest(http.MethodPost, path, request) 269 | if err != nil { 270 | return nil, fmt.Errorf("error creating http request: %w", err) 271 | } 272 | 273 | resp := &DataImport{} 274 | if err := d.client.do(ctx, req, &resp); err != nil { 275 | return nil, err 276 | } 277 | 278 | return resp, nil 279 | } 280 | 281 | func (d *dataImportsService) GetDataImportStatus(ctx context.Context, getReq *GetImportStatusRequest) (*DataImport, error) { 282 | path := dataImportAPIPath(getReq.Organization, getReq.Database) 283 | req, err := d.client.newRequest(http.MethodGet, path, nil) 284 | if err != nil { 285 | return nil, fmt.Errorf("error creating request for get database: %w", err) 286 | } 287 | 288 | di := &DataImport{} 289 | err = d.client.do(ctx, req, &di) 290 | if err != nil { 291 | return nil, err 292 | } 293 | 294 | di.ParseState() 295 | return di, nil 296 | } 297 | 298 | func (d *dataImportsService) CancelDataImport(ctx context.Context, cancelReq *CancelDataImportRequest) error { 299 | path := fmt.Sprintf("%s/cancel", dataImportAPIPath(cancelReq.Organization, cancelReq.Database)) 300 | req, err := d.client.newRequest(http.MethodPost, path, nil) 301 | if err != nil { 302 | return fmt.Errorf("error creating http request: %w", err) 303 | } 304 | 305 | if err := d.client.do(ctx, req, nil); err != nil { 306 | return err 307 | } 308 | 309 | return nil 310 | } 311 | 312 | func (d *dataImportsService) MakePlanetScalePrimary(ctx context.Context, request *MakePlanetScalePrimaryRequest) (*DataImport, error) { 313 | path := fmt.Sprintf("%s/begin-switch-traffic", dataImportAPIPath(request.Organization, request.Database)) 314 | req, err := d.client.newRequest(http.MethodPost, path, nil) 315 | if err != nil { 316 | return nil, fmt.Errorf("error creating http request: %w", err) 317 | } 318 | 319 | resp := &DataImport{} 320 | if err := d.client.do(ctx, req, &resp); err != nil { 321 | return nil, err 322 | } 323 | resp.ParseState() 324 | return resp, nil 325 | } 326 | 327 | func (d *dataImportsService) MakePlanetScaleReplica(ctx context.Context, request *MakePlanetScaleReplicaRequest) (*DataImport, error) { 328 | path := fmt.Sprintf("%s/begin-reverse-traffic", dataImportAPIPath(request.Organization, request.Database)) 329 | req, err := d.client.newRequest(http.MethodPost, path, nil) 330 | if err != nil { 331 | return nil, fmt.Errorf("error creating http request: %w", err) 332 | } 333 | 334 | resp := &DataImport{} 335 | if err := d.client.do(ctx, req, &resp); err != nil { 336 | return nil, err 337 | } 338 | resp.ParseState() 339 | return resp, nil 340 | } 341 | 342 | func (d *dataImportsService) DetachExternalDatabase(ctx context.Context, request *DetachExternalDatabaseRequest) (*DataImport, error) { 343 | path := fmt.Sprintf("%s/detach-external-database", dataImportAPIPath(request.Organization, request.Database)) 344 | req, err := d.client.newRequest(http.MethodPost, path, nil) 345 | if err != nil { 346 | return nil, fmt.Errorf("error creating http request: %w", err) 347 | } 348 | 349 | resp := &DataImport{} 350 | if err := d.client.do(ctx, req, &resp); err != nil { 351 | return nil, err 352 | } 353 | 354 | resp.ParseState() 355 | return resp, nil 356 | } 357 | 358 | func dataImportAPIPath(organization, database string) string { 359 | return fmt.Sprintf("/v1/organizations/%s/databases/%s/data-imports", organization, database) 360 | } 361 | -------------------------------------------------------------------------------- /planetscale/imports_test.go: -------------------------------------------------------------------------------- 1 | package planetscale 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "net/http" 8 | "net/http/httptest" 9 | "testing" 10 | 11 | qt "github.com/frankban/quicktest" 12 | ) 13 | 14 | var knownStates = map[string]DataImportState{ 15 | "prepare_data_copy_pending": DataImportPreparingDataCopy, 16 | "prepare_data_copy_error": DataImportPreparingDataCopyFailed, 17 | "data_copy_pending": DataImportCopyingData, 18 | "data_copy_error": DataImportCopyingDataFailed, 19 | "switch_traffic_workflow_pending": DataImportSwitchTrafficPending, 20 | "switch_traffic_workflow_running": DataImportSwitchTrafficRunning, 21 | "switch_traffic_workflow_error": DataImportSwitchTrafficError, 22 | "reverse_traffic_workflow_running": DataImportReverseTrafficRunning, 23 | "cleanup_workflow_pending": DataImportSwitchTrafficCompleted, 24 | "cleanup_workflow_running": DataImportDetachExternalDatabaseRunning, 25 | "cleanup_workflow_error": DataImportDetachExternalDatabaseError, 26 | "ready": DataImportReady, 27 | } 28 | 29 | func TestImports_ParseState(t *testing.T) { 30 | c := qt.New(t) 31 | for state, importState := range knownStates { 32 | t.Run(fmt.Sprintf("Can parse state : %s", state), func(t *testing.T) { 33 | di := DataImport{ 34 | State: state, 35 | } 36 | 37 | di.ParseState() 38 | 39 | c.Assert(di.ImportState, qt.Equals, importState) 40 | }) 41 | } 42 | } 43 | 44 | func TestImports_CanRunLintExternalDatabase_Success(t *testing.T) { 45 | c := qt.New(t) 46 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 47 | c.Assert(r.URL.Path, qt.Equals, "/v1/organizations/my-org/data-imports/test-connection") 48 | w.WriteHeader(200) 49 | out := `{ "can_connect": true, "error": "", "lint_errors": [], "table_statuses": []}` 50 | _, err := w.Write([]byte(out)) 51 | c.Assert(err, qt.IsNil) 52 | })) 53 | 54 | client, err := NewClient(WithBaseURL(ts.URL)) 55 | c.Assert(err, qt.IsNil) 56 | ctx := context.Background() 57 | org := "my-org" 58 | db := "my-db" 59 | td := TestDataImportSourceRequest{ 60 | Organization: org, 61 | Database: db, 62 | Connection: DataImportSource{}, 63 | } 64 | 65 | results, err := client.DataImports.TestDataImportSource(ctx, &td) 66 | c.Assert(err, qt.IsNil) 67 | 68 | c.Assert(true, qt.Equals, results.CanConnect) 69 | } 70 | 71 | func TestImports_CanRunLintExternalDatabase_ConnectFailure(t *testing.T) { 72 | c := qt.New(t) 73 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 74 | c.Assert(r.URL.Path, qt.Equals, "/v1/organizations/my-org/data-imports/test-connection") 75 | w.WriteHeader(200) 76 | out := `{ "can_connect": false, "error": "external database is down", "lint_errors": [], "table_statuses": []}` 77 | _, err := w.Write([]byte(out)) 78 | c.Assert(err, qt.IsNil) 79 | })) 80 | 81 | client, err := NewClient(WithBaseURL(ts.URL)) 82 | c.Assert(err, qt.IsNil) 83 | ctx := context.Background() 84 | org := "my-org" 85 | db := "my-db" 86 | td := TestDataImportSourceRequest{ 87 | Organization: org, 88 | Database: db, 89 | Connection: DataImportSource{}, 90 | } 91 | 92 | results, err := client.DataImports.TestDataImportSource(ctx, &td) 93 | c.Assert(err, qt.IsNil) 94 | 95 | c.Assert(false, qt.Equals, results.CanConnect) 96 | c.Assert("external database is down", qt.Equals, results.ConnectError) 97 | } 98 | 99 | func TestImports_CanRunLintExternalDatabase_LintFailure(t *testing.T) { 100 | c := qt.New(t) 101 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 102 | c.Assert(r.URL.Path, qt.Equals, "/v1/organizations/my-org/data-imports/test-connection") 103 | 104 | w.WriteHeader(200) 105 | out := `{ 106 | "can_connect": true, 107 | "error": "", 108 | "lint_errors": [{ 109 | "lint_error": "NO_PRIMARY_KEY", 110 | "table_name": "employees", 111 | "error_description": "Table 'employees' has no primary key" 112 | }], 113 | "table_statuses": [] 114 | }` 115 | _, err := w.Write([]byte(out)) 116 | c.Assert(err, qt.IsNil) 117 | })) 118 | 119 | client, err := NewClient(WithBaseURL(ts.URL)) 120 | c.Assert(err, qt.IsNil) 121 | ctx := context.Background() 122 | org := "my-org" 123 | db := "my-db" 124 | td := TestDataImportSourceRequest{ 125 | Organization: org, 126 | Database: db, 127 | Connection: DataImportSource{}, 128 | } 129 | 130 | results, err := client.DataImports.TestDataImportSource(ctx, &td) 131 | c.Assert(err, qt.IsNil) 132 | 133 | c.Assert(true, qt.Equals, results.CanConnect) 134 | c.Assert("", qt.Equals, results.ConnectError) 135 | c.Assert(1, qt.Equals, len(results.Errors)) 136 | c.Assert([]*DataSourceIncompatibilityError{ 137 | { 138 | LintError: "NO_PRIMARY_KEY", 139 | Table: "employees", 140 | ErrorDescription: "Table 'employees' has no primary key", 141 | }, 142 | }, qt.DeepEquals, results.Errors) 143 | } 144 | 145 | func TestImports_CanRunLintExternalDatabase_NeedsUpgrade(t *testing.T) { 146 | c := qt.New(t) 147 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 148 | c.Assert(r.URL.Path, qt.Equals, "/v1/organizations/my-org/data-imports/test-connection") 149 | w.WriteHeader(200) 150 | out := `{ 151 | "can_connect": true, 152 | "error": "", 153 | "should_upgrade": true, 154 | "table_statuses": [] 155 | }` 156 | _, err := w.Write([]byte(out)) 157 | c.Assert(err, qt.IsNil) 158 | })) 159 | 160 | client, err := NewClient(WithBaseURL(ts.URL)) 161 | c.Assert(err, qt.IsNil) 162 | ctx := context.Background() 163 | org := "my-org" 164 | db := "my-db" 165 | td := TestDataImportSourceRequest{ 166 | Organization: org, 167 | Database: db, 168 | Connection: DataImportSource{}, 169 | } 170 | 171 | _, err = client.DataImports.TestDataImportSource(ctx, &td) 172 | c.Assert(err, qt.IsNotNil) 173 | c.Assert(err, qt.ErrorIs, UserShouldUpgradePlanError{}) 174 | } 175 | 176 | func TestImports_CanStartDataImport_Success(t *testing.T) { 177 | c := qt.New(t) 178 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 179 | c.Assert(r.URL.Path, qt.Equals, "/v1/organizations/my-org/data-imports/new") 180 | var startRequest StartDataImportRequest 181 | err := json.NewDecoder(r.Body).Decode(&startRequest) 182 | c.Assert(err, qt.IsNil) 183 | c.Assert("us-west-2", qt.Equals, startRequest.Region) 184 | 185 | w.WriteHeader(200) 186 | out := `{ 187 | "id": "PUBLIC_ID", 188 | "state": "prepare_data_copy_pending", 189 | "import_check_errors": "", 190 | "data_source": { 191 | "hostname": "aws.rds.something.com", 192 | "port": 25060, 193 | "database": "employees" 194 | } 195 | }` 196 | _, err = w.Write([]byte(out)) 197 | c.Assert(err, qt.IsNil) 198 | })) 199 | 200 | client, err := NewClient(WithBaseURL(ts.URL)) 201 | c.Assert(err, qt.IsNil) 202 | ctx := context.Background() 203 | org := "my-org" 204 | db := "my-db" 205 | 206 | startReq := &StartDataImportRequest{ 207 | Organization: org, 208 | Database: db, 209 | Region: "us-west-2", 210 | } 211 | di, err := client.DataImports.StartDataImport(ctx, startReq) 212 | c.Assert(err, qt.IsNil) 213 | c.Assert(di.ID, qt.Equals, "PUBLIC_ID") 214 | c.Assert(di.Errors, qt.Equals, "") 215 | c.Assert(di.ImportState, qt.Equals, DataImportPreparingDataCopy) 216 | } 217 | 218 | func TestImports_CanGetDataImportStatus_Success(t *testing.T) { 219 | c := qt.New(t) 220 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 221 | c.Assert(r.URL.Path, qt.Equals, "/v1/organizations/my-org/databases/my-db/data-imports") 222 | w.WriteHeader(200) 223 | out := `{ 224 | "id": "IMPORT_PUBLIC_ID", 225 | "state": "switch_traffic_workflow_pending" 226 | }` 227 | _, err := w.Write([]byte(out)) 228 | c.Assert(err, qt.IsNil) 229 | })) 230 | 231 | client, err := NewClient(WithBaseURL(ts.URL)) 232 | c.Assert(err, qt.IsNil) 233 | ctx := context.Background() 234 | org := "my-org" 235 | db := "my-db" 236 | di, err := client.DataImports.GetDataImportStatus(ctx, &GetImportStatusRequest{ 237 | Organization: org, 238 | Database: db, 239 | }) 240 | c.Assert(err, qt.IsNil) 241 | c.Assert(di.ID, qt.Equals, "IMPORT_PUBLIC_ID") 242 | c.Assert(di.ImportState, qt.Equals, DataImportSwitchTrafficPending) 243 | } 244 | 245 | func TestImports_CanGetDataImportStatus_NoImport(t *testing.T) { 246 | c := qt.New(t) 247 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 248 | c.Assert(r.URL.Path, qt.Equals, "/v1/organizations/my-org/databases/my-db/data-imports") 249 | w.WriteHeader(400) 250 | out := `{ 251 | "code": "bad_request", 252 | "message": "Data import has not been setup for this database." 253 | }` 254 | _, err := w.Write([]byte(out)) 255 | c.Assert(err, qt.IsNil) 256 | })) 257 | 258 | client, err := NewClient(WithBaseURL(ts.URL)) 259 | c.Assert(err, qt.IsNil) 260 | ctx := context.Background() 261 | org := "my-org" 262 | db := "my-db" 263 | di, err := client.DataImports.GetDataImportStatus(ctx, &GetImportStatusRequest{ 264 | Organization: org, 265 | Database: db, 266 | }) 267 | c.Assert(err, qt.IsNotNil) 268 | c.Assert(err, qt.ErrorMatches, "Data import has not been setup for this database.") 269 | c.Assert(di, qt.IsNil) 270 | } 271 | 272 | func TestImports_CanCancelDataImport(t *testing.T) { 273 | c := qt.New(t) 274 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 275 | c.Assert(r.URL.Path, qt.Equals, "/v1/organizations/my-org/databases/my-db/data-imports/cancel") 276 | w.WriteHeader(200) 277 | out := `{ 278 | "id": "PUBLIC_ID", 279 | "state": "prepare_data_copy_pending", 280 | "import_check_errors": "", 281 | "data_source": { 282 | "hostname": "aws.rds.something.com", 283 | "port": "25060", 284 | "database": "employees" 285 | } 286 | }` 287 | _, err := w.Write([]byte(out)) 288 | c.Assert(err, qt.IsNil) 289 | })) 290 | 291 | client, err := NewClient(WithBaseURL(ts.URL)) 292 | c.Assert(err, qt.IsNil) 293 | ctx := context.Background() 294 | org := "my-org" 295 | db := "my-db" 296 | err = client.DataImports.CancelDataImport(ctx, &CancelDataImportRequest{ 297 | Organization: org, 298 | Database: db, 299 | }) 300 | c.Assert(err, qt.IsNil) 301 | } 302 | 303 | func TestImports_CanMakePlanetScalePrimary(t *testing.T) { 304 | c := qt.New(t) 305 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 306 | c.Assert(r.URL.Path, qt.Equals, "/v1/organizations/my-org/databases/my-db/data-imports/begin-switch-traffic") 307 | w.WriteHeader(200) 308 | out := `{ 309 | "id": "PUBLIC_ID", 310 | "state": "cleanup_workflow_pending", 311 | "import_check_errors": "", 312 | "data_source": { 313 | "hostname": "aws.rds.something.com", 314 | "port": 25060, 315 | "database": "employees" 316 | } 317 | }` 318 | _, err := w.Write([]byte(out)) 319 | c.Assert(err, qt.IsNil) 320 | })) 321 | 322 | client, err := NewClient(WithBaseURL(ts.URL)) 323 | c.Assert(err, qt.IsNil) 324 | ctx := context.Background() 325 | org := "my-org" 326 | db := "my-db" 327 | 328 | makePrimRequest := &MakePlanetScalePrimaryRequest{ 329 | Organization: org, 330 | Database: db, 331 | } 332 | di, err := client.DataImports.MakePlanetScalePrimary(ctx, makePrimRequest) 333 | c.Assert(err, qt.IsNil) 334 | c.Assert(di.ID, qt.Equals, "PUBLIC_ID") 335 | c.Assert(di.Errors, qt.Equals, "") 336 | c.Assert(di.ImportState, qt.Equals, DataImportSwitchTrafficCompleted) 337 | } 338 | 339 | func TestImports_CanMakePlanetScaleReplica(t *testing.T) { 340 | c := qt.New(t) 341 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 342 | c.Assert(r.URL.Path, qt.Equals, "/v1/organizations/my-org/databases/my-db/data-imports/begin-reverse-traffic") 343 | w.WriteHeader(200) 344 | out := `{ 345 | "id": "PUBLIC_ID", 346 | "state": "switch_traffic_workflow_pending", 347 | "import_check_errors": "", 348 | "data_source": { 349 | "hostname": "aws.rds.something.com", 350 | "port": 25060, 351 | "database": "employees" 352 | } 353 | }` 354 | _, err := w.Write([]byte(out)) 355 | c.Assert(err, qt.IsNil) 356 | })) 357 | 358 | client, err := NewClient(WithBaseURL(ts.URL)) 359 | c.Assert(err, qt.IsNil) 360 | ctx := context.Background() 361 | org := "my-org" 362 | db := "my-db" 363 | 364 | makeReplicaRequest := &MakePlanetScaleReplicaRequest{ 365 | Organization: org, 366 | Database: db, 367 | } 368 | di, err := client.DataImports.MakePlanetScaleReplica(ctx, makeReplicaRequest) 369 | c.Assert(err, qt.IsNil) 370 | c.Assert(di.ID, qt.Equals, "PUBLIC_ID") 371 | c.Assert(di.Errors, qt.Equals, "") 372 | c.Assert(di.ImportState, qt.Equals, DataImportSwitchTrafficPending) 373 | } 374 | 375 | func TestImports_CanDetachExternalDatabase(t *testing.T) { 376 | c := qt.New(t) 377 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 378 | c.Assert(r.URL.Path, qt.Equals, "/v1/organizations/my-org/databases/my-db/data-imports/detach-external-database") 379 | w.WriteHeader(200) 380 | out := `{ 381 | "id": "PUBLIC_ID", 382 | "state": "ready", 383 | "import_check_errors": "", 384 | "data_source": { 385 | "hostname": "aws.rds.something.com", 386 | "port": 25060, 387 | "database": "employees" 388 | } 389 | }` 390 | _, err := w.Write([]byte(out)) 391 | c.Assert(err, qt.IsNil) 392 | })) 393 | 394 | client, err := NewClient(WithBaseURL(ts.URL)) 395 | c.Assert(err, qt.IsNil) 396 | ctx := context.Background() 397 | org := "my-org" 398 | db := "my-db" 399 | 400 | detachReq := &DetachExternalDatabaseRequest{ 401 | Organization: org, 402 | Database: db, 403 | } 404 | di, err := client.DataImports.DetachExternalDatabase(ctx, detachReq) 405 | c.Assert(err, qt.IsNil) 406 | c.Assert(di.ID, qt.Equals, "PUBLIC_ID") 407 | c.Assert(di.Errors, qt.Equals, "") 408 | c.Assert(di.ImportState, qt.Equals, DataImportReady) 409 | } 410 | -------------------------------------------------------------------------------- /planetscale/integration_test.go: -------------------------------------------------------------------------------- 1 | //go:build integration 2 | // +build integration 3 | 4 | package planetscale 5 | 6 | import ( 7 | "context" 8 | "fmt" 9 | "os" 10 | "testing" 11 | "time" 12 | ) 13 | 14 | // This integration test creates, lists and then deletes a PlanetScale 15 | // Database. Use with caution!. Usage: 16 | // 17 | // PLANETSCALE_TOKEN=$(cat ~/.config/planetscale/access-token) PLANETSCALE_ORG="damp-dew-9934" go test -tags integration 18 | // 19 | 20 | func TestIntegration_Databases_List(t *testing.T) { 21 | token := os.Getenv("PLANETSCALE_TOKEN") 22 | if token == "" { 23 | t.Fatalf("PLANETSCALE_TOKEN is not set") 24 | } 25 | 26 | org := os.Getenv("PLANETSCALE_ORG") 27 | if org == "" { 28 | t.Fatalf("PLANETSCALE_ORG is not set") 29 | } 30 | 31 | ctx := context.Background() 32 | 33 | client, err := NewClient( 34 | WithAccessToken(token), 35 | ) 36 | if err != nil { 37 | t.Fatal(err) 38 | } 39 | 40 | dbName := "planetscale-go-test-db" 41 | 42 | _, err = client.Databases.Create(ctx, &CreateDatabaseRequest{ 43 | Organization: org, 44 | Name: dbName, 45 | }) 46 | if err != nil { 47 | t.Fatalf("create database failed: %s", err) 48 | } 49 | 50 | // poor mans polling, remove once we have an API to poll the status of the DB 51 | time.Sleep(time.Second * 2) 52 | 53 | dbs, err := client.Databases.List(ctx, &ListDatabasesRequest{ 54 | Organization: org, 55 | }) 56 | if err != nil { 57 | t.Fatalf("list database failed: %s", err) 58 | } 59 | 60 | fmt.Printf("Found %d databases\n", len(dbs)) 61 | for _, db := range dbs { 62 | fmt.Printf("db struct = %+v\n", db) 63 | fmt.Println("----------") 64 | fmt.Printf("Name: %q\n", db.Name) 65 | fmt.Printf("Notes: %q\n", db.Notes) 66 | } 67 | 68 | err = client.Databases.Delete(ctx, &DeleteDatabaseRequest{ 69 | Organization: org, 70 | Database: dbName, 71 | }) 72 | if err != nil { 73 | t.Fatalf("delete database failed: %s", err) 74 | } 75 | } 76 | 77 | func TestIntegration_AuditLogs_List(t *testing.T) { 78 | token := os.Getenv("PLANETSCALE_TOKEN") 79 | if token == "" { 80 | t.Fatalf("PLANETSCALE_TOKEN is not set") 81 | } 82 | 83 | org := os.Getenv("PLANETSCALE_ORG") 84 | if org == "" { 85 | t.Fatalf("PLANETSCALE_ORG is not set") 86 | } 87 | 88 | ctx := context.Background() 89 | 90 | client, err := NewClient( 91 | WithAccessToken(token), 92 | ) 93 | if err != nil { 94 | t.Fatal(err) 95 | } 96 | 97 | auditLogs, err := client.AuditLogs.List(ctx, &ListAuditLogsRequest{ 98 | Organization: org, 99 | Events: []AuditLogEvent{ 100 | AuditLogEventBranchDeleted, 101 | AuditLogEventOrganizationJoined, 102 | }, 103 | }) 104 | if err != nil { 105 | t.Fatalf("get audit logs failed: %s", err) 106 | } 107 | 108 | for _, l := range auditLogs { 109 | fmt.Printf("l. = %+v\n", l.AuditAction) 110 | } 111 | fmt.Printf("len(auditLogs) = %+v\n", len(auditLogs)) 112 | } 113 | -------------------------------------------------------------------------------- /planetscale/keyspaces.go: -------------------------------------------------------------------------------- 1 | package planetscale 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | "time" 8 | ) 9 | 10 | type Keyspace struct { 11 | ID string `json:"id"` 12 | Name string `json:"name"` 13 | Shards int `json:"shards"` 14 | Sharded bool `json:"sharded"` 15 | Replicas uint64 `json:"replicas"` 16 | ExtraReplicas uint64 `json:"extra_replicas"` 17 | ResizePending bool `json:"resize_pending"` 18 | Resizing bool `json:"resizing"` 19 | Ready bool `json:"ready"` 20 | ClusterSize string `json:"cluster_name"` 21 | CreatedAt time.Time `json:"created_at"` 22 | UpdatedAt time.Time `json:"updated_at"` 23 | VReplicationFlags *VReplicationFlags `json:"vreplication_flags"` 24 | ReplicationDurabilityConstraints *ReplicationDurabilityConstraints `json:"replication_durability_constraints"` 25 | } 26 | 27 | // VSchema represnts the VSchema for a branch keyspace 28 | type VSchema struct { 29 | Raw string `json:"raw"` 30 | HTML string `json:"html"` 31 | } 32 | 33 | type ListKeyspacesRequest struct { 34 | Organization string `json:"-"` 35 | Database string `json:"-"` 36 | Branch string `json:"-"` 37 | } 38 | 39 | type CreateKeyspaceRequest struct { 40 | Organization string `json:"-"` 41 | Database string `json:"-"` 42 | Branch string `json:"-"` 43 | Name string `json:"name"` 44 | ClusterSize string `json:"cluster_size"` 45 | ExtraReplicas int `json:"extra_replicas"` 46 | Shards int `json:"shards"` 47 | } 48 | 49 | type GetKeyspaceRequest struct { 50 | Organization string `json:"-"` 51 | Database string `json:"-"` 52 | Branch string `json:"-"` 53 | Keyspace string `json:"-"` 54 | } 55 | 56 | type GetKeyspaceVSchemaRequest struct { 57 | Organization string `json:"-"` 58 | Database string `json:"-"` 59 | Branch string `json:"-"` 60 | Keyspace string `json:"-"` 61 | } 62 | 63 | type UpdateKeyspaceVSchemaRequest struct { 64 | Organization string `json:"-"` 65 | Database string `json:"-"` 66 | Branch string `json:"-"` 67 | Keyspace string `json:"-"` 68 | VSchema string `json:"vschema"` 69 | } 70 | 71 | type keyspacesResponse struct { 72 | Keyspaces []*Keyspace `json:"data"` 73 | } 74 | 75 | type ResizeKeyspaceRequest struct { 76 | Organization string `json:"-"` 77 | Database string `json:"-"` 78 | Branch string `json:"-"` 79 | Keyspace string `json:"-"` 80 | ExtraReplicas *uint `json:"extra_replicas,omitempty"` 81 | ClusterSize *string `json:"cluster_size,omitempty"` 82 | } 83 | 84 | type KeyspaceResizeRequest struct { 85 | ID string `json:"id"` 86 | State string `json:"state"` 87 | Actor *Actor `json:"actor"` 88 | 89 | ClusterSize string `json:"cluster_name"` 90 | PreviousClusterSize string `json:"previous_cluster_name"` 91 | 92 | Replicas uint `json:"replicas"` 93 | ExtraReplicas uint `json:"extra_replicas"` 94 | PreviousReplicas uint `json:"previous_replicas"` 95 | 96 | UpdatedAt time.Time `json:"updated_at"` 97 | CreatedAt time.Time `json:"created_at"` 98 | StartedAt *time.Time `json:"started_at"` 99 | CompletedAt *time.Time `json:"completed_at"` 100 | } 101 | 102 | type KeyspaceRollout struct { 103 | Name string `json:"name"` 104 | State string `json:"state"` 105 | 106 | Shards []ShardRollout `json:"shards"` 107 | } 108 | 109 | type ShardRollout struct { 110 | Name string `json:"name"` 111 | State string `json:"state"` 112 | 113 | LastRolloutStartedAt time.Time `json:"last_rollout_started_at"` 114 | LastRolloutFinishedAt time.Time `json:"last_rollout_finished_at"` 115 | } 116 | 117 | type CancelKeyspaceResizeRequest struct { 118 | Organization string `json:"-"` 119 | Database string `json:"-"` 120 | Branch string `json:"-"` 121 | Keyspace string `json:"-"` 122 | } 123 | 124 | type KeyspaceResizeStatusRequest struct { 125 | Organization string `json:"-"` 126 | Database string `json:"-"` 127 | Branch string `json:"-"` 128 | Keyspace string `json:"-"` 129 | } 130 | 131 | type KeyspaceRolloutStatusRequest struct { 132 | Organization string `json:"-"` 133 | Database string `json:"-"` 134 | Branch string `json:"-"` 135 | Keyspace string `json:"-"` 136 | } 137 | 138 | type UpdateKeyspaceSettingsRequest struct { 139 | Organization string `json:"-"` 140 | Database string `json:"-"` 141 | Branch string `json:"-"` 142 | Keyspace string `json:"-"` 143 | ReplicationDurabilityConstraints *ReplicationDurabilityConstraints `json:"replication_durability_constraints,omitempty"` 144 | VReplicationFlags *VReplicationFlags `json:"vreplication_flags,omitempty"` 145 | } 146 | 147 | type ReplicationDurabilityConstraints struct { 148 | Strategy string `json:"strategy"` 149 | } 150 | 151 | type VReplicationFlags struct { 152 | OptimizeInserts bool `json:"optimize_inserts"` 153 | AllowNoBlobBinlogRowImage bool `json:"allow_no_blob_binlog_row_image"` 154 | VPlayerBatching bool `json:"vplayer_batching"` 155 | } 156 | 157 | // KeyspacesService is an interface for interacting with the keyspace endpoints of the PlanetScale API 158 | type KeyspacesService interface { 159 | Create(context.Context, *CreateKeyspaceRequest) (*Keyspace, error) 160 | List(context.Context, *ListKeyspacesRequest) ([]*Keyspace, error) 161 | Get(context.Context, *GetKeyspaceRequest) (*Keyspace, error) 162 | VSchema(context.Context, *GetKeyspaceVSchemaRequest) (*VSchema, error) 163 | UpdateVSchema(context.Context, *UpdateKeyspaceVSchemaRequest) (*VSchema, error) 164 | Resize(context.Context, *ResizeKeyspaceRequest) (*KeyspaceResizeRequest, error) 165 | CancelResize(context.Context, *CancelKeyspaceResizeRequest) error 166 | ResizeStatus(context.Context, *KeyspaceResizeStatusRequest) (*KeyspaceResizeRequest, error) 167 | RolloutStatus(context.Context, *KeyspaceRolloutStatusRequest) (*KeyspaceRollout, error) 168 | UpdateSettings(context.Context, *UpdateKeyspaceSettingsRequest) (*Keyspace, error) 169 | } 170 | 171 | type keyspacesService struct { 172 | client *Client 173 | } 174 | 175 | var _ KeyspacesService = &keyspacesService{} 176 | 177 | func NewKeyspacesService(client *Client) *keyspacesService { 178 | return &keyspacesService{client} 179 | } 180 | 181 | // List returns a list of keyspaces for a branch 182 | func (s *keyspacesService) List(ctx context.Context, listReq *ListKeyspacesRequest) ([]*Keyspace, error) { 183 | req, err := s.client.newRequest(http.MethodGet, keyspacesAPIPath(listReq.Organization, listReq.Database, listReq.Branch), nil) 184 | if err != nil { 185 | return nil, fmt.Errorf("error creating http request: %w", err) 186 | } 187 | 188 | keyspaces := &keyspacesResponse{} 189 | if err := s.client.do(ctx, req, keyspaces); err != nil { 190 | return nil, err 191 | } 192 | 193 | return keyspaces.Keyspaces, nil 194 | } 195 | 196 | // Get returns a keyspace for a branch 197 | func (s *keyspacesService) Get(ctx context.Context, getReq *GetKeyspaceRequest) (*Keyspace, error) { 198 | req, err := s.client.newRequest(http.MethodGet, keyspaceAPIPath(getReq.Organization, getReq.Database, getReq.Branch, getReq.Keyspace), nil) 199 | if err != nil { 200 | return nil, fmt.Errorf("error creating http request: %w", err) 201 | } 202 | 203 | keyspace := &Keyspace{} 204 | if err := s.client.do(ctx, req, keyspace); err != nil { 205 | return nil, err 206 | } 207 | 208 | return keyspace, nil 209 | } 210 | 211 | // Create creates a keyspace for a branch 212 | func (s *keyspacesService) Create(ctx context.Context, createReq *CreateKeyspaceRequest) (*Keyspace, error) { 213 | req, err := s.client.newRequest(http.MethodPost, keyspacesAPIPath(createReq.Organization, createReq.Database, createReq.Branch), createReq) 214 | if err != nil { 215 | return nil, fmt.Errorf("error creating http request: %w", err) 216 | } 217 | 218 | keyspace := &Keyspace{} 219 | if err := s.client.do(ctx, req, keyspace); err != nil { 220 | return nil, err 221 | } 222 | 223 | return keyspace, nil 224 | } 225 | 226 | // VSchema returns the VSchema for a keyspace in a branch 227 | func (s *keyspacesService) VSchema(ctx context.Context, getReq *GetKeyspaceVSchemaRequest) (*VSchema, error) { 228 | path := fmt.Sprintf("%s/vschema", keyspaceAPIPath(getReq.Organization, getReq.Database, getReq.Branch, getReq.Keyspace)) 229 | req, err := s.client.newRequest(http.MethodGet, path, nil) 230 | if err != nil { 231 | return nil, fmt.Errorf("error creating http request: %w", err) 232 | } 233 | 234 | vschema := &VSchema{} 235 | if err := s.client.do(ctx, req, vschema); err != nil { 236 | return nil, err 237 | } 238 | 239 | return vschema, nil 240 | } 241 | 242 | func (s *keyspacesService) UpdateVSchema(ctx context.Context, updateReq *UpdateKeyspaceVSchemaRequest) (*VSchema, error) { 243 | path := fmt.Sprintf("%s/vschema", keyspaceAPIPath(updateReq.Organization, updateReq.Database, updateReq.Branch, updateReq.Keyspace)) 244 | req, err := s.client.newRequest(http.MethodPatch, path, updateReq) 245 | if err != nil { 246 | return nil, fmt.Errorf("error creating http request: %w", err) 247 | } 248 | 249 | vschema := &VSchema{} 250 | if err := s.client.do(ctx, req, vschema); err != nil { 251 | return nil, err 252 | } 253 | 254 | return vschema, nil 255 | } 256 | 257 | // Resize starts or queues a resize of a branch's keyspace. 258 | func (s *keyspacesService) Resize(ctx context.Context, resizeReq *ResizeKeyspaceRequest) (*KeyspaceResizeRequest, error) { 259 | req, err := s.client.newRequest(http.MethodPut, keyspaceResizesAPIPath(resizeReq.Organization, resizeReq.Database, resizeReq.Branch, resizeReq.Keyspace), resizeReq) 260 | if err != nil { 261 | return nil, fmt.Errorf("error creating http request: %w", err) 262 | } 263 | 264 | keyspaceResize := &KeyspaceResizeRequest{} 265 | if err := s.client.do(ctx, req, keyspaceResize); err != nil { 266 | return nil, err 267 | } 268 | 269 | return keyspaceResize, nil 270 | } 271 | 272 | // CancelResize cancels a queued resize of a branch's keyspace. 273 | func (s *keyspacesService) CancelResize(ctx context.Context, cancelReq *CancelKeyspaceResizeRequest) error { 274 | req, err := s.client.newRequest(http.MethodDelete, keyspaceResizesAPIPath(cancelReq.Organization, cancelReq.Database, cancelReq.Branch, cancelReq.Keyspace), nil) 275 | if err != nil { 276 | return fmt.Errorf("error creating http request: %w", err) 277 | } 278 | 279 | return s.client.do(ctx, req, nil) 280 | } 281 | 282 | func keyspacesAPIPath(org, db, branch string) string { 283 | return fmt.Sprintf("%s/keyspaces", databaseBranchAPIPath(org, db, branch)) 284 | } 285 | 286 | func keyspaceAPIPath(org, db, branch, keyspace string) string { 287 | return fmt.Sprintf("%s/%s", keyspacesAPIPath(org, db, branch), keyspace) 288 | } 289 | 290 | func keyspaceResizesAPIPath(org, db, branch, keyspace string) string { 291 | return fmt.Sprintf("%s/resizes", keyspaceAPIPath(org, db, branch, keyspace)) 292 | } 293 | 294 | type keyspaceResizesResponse struct { 295 | Resizes []*KeyspaceResizeRequest `json:"data"` 296 | } 297 | 298 | func (s *keyspacesService) ResizeStatus(ctx context.Context, resizeReq *KeyspaceResizeStatusRequest) (*KeyspaceResizeRequest, error) { 299 | req, err := s.client.newRequest(http.MethodGet, keyspaceResizesAPIPath(resizeReq.Organization, resizeReq.Database, resizeReq.Branch, resizeReq.Keyspace), nil) 300 | if err != nil { 301 | return nil, fmt.Errorf("error creating http request: %w", err) 302 | } 303 | 304 | resizesResponse := &keyspaceResizesResponse{} 305 | if err := s.client.do(ctx, req, resizesResponse); err != nil { 306 | return nil, err 307 | } 308 | 309 | // If there are no resizes, treat the same as a not found error 310 | if len(resizesResponse.Resizes) == 0 { 311 | return nil, &Error{ 312 | msg: "Not Found", 313 | Code: ErrNotFound, 314 | } 315 | } 316 | 317 | return resizesResponse.Resizes[0], nil 318 | } 319 | 320 | func keyspaceRolloutStatusAPIPath(org, db, branch, keyspace string) string { 321 | return fmt.Sprintf("%s/rollout-status", keyspaceAPIPath(org, db, branch, keyspace)) 322 | } 323 | 324 | func (s *keyspacesService) RolloutStatus(ctx context.Context, rolloutReq *KeyspaceRolloutStatusRequest) (*KeyspaceRollout, error) { 325 | req, err := s.client.newRequest(http.MethodGet, keyspaceRolloutStatusAPIPath(rolloutReq.Organization, rolloutReq.Database, rolloutReq.Branch, rolloutReq.Keyspace), nil) 326 | if err != nil { 327 | return nil, fmt.Errorf("error creating http request: %w", err) 328 | } 329 | 330 | rolloutStatusResponse := &KeyspaceRollout{} 331 | if err := s.client.do(ctx, req, rolloutStatusResponse); err != nil { 332 | return nil, err 333 | } 334 | 335 | return rolloutStatusResponse, nil 336 | } 337 | 338 | func (s *keyspacesService) UpdateSettings(ctx context.Context, updateReq *UpdateKeyspaceSettingsRequest) (*Keyspace, error) { 339 | req, err := s.client.newRequest(http.MethodPatch, keyspaceAPIPath(updateReq.Organization, updateReq.Database, updateReq.Branch, updateReq.Keyspace), updateReq) 340 | if err != nil { 341 | return nil, fmt.Errorf("error creating http request: %w", err) 342 | } 343 | 344 | keyspace := &Keyspace{} 345 | if err := s.client.do(ctx, req, keyspace); err != nil { 346 | return nil, err 347 | } 348 | 349 | return keyspace, nil 350 | } 351 | -------------------------------------------------------------------------------- /planetscale/keyspaces_test.go: -------------------------------------------------------------------------------- 1 | package planetscale 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "net/http/httptest" 7 | "testing" 8 | 9 | qt "github.com/frankban/quicktest" 10 | ) 11 | 12 | func TestKeyspaces_List(t *testing.T) { 13 | c := qt.New(t) 14 | 15 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 16 | w.WriteHeader(200) 17 | out := `{"type":"list","current_page":1,"next_page":null,"next_page_url":null,"prev_page":null,"prev_page_url":null,"data":[{"id":"thisisanid","type":"Keyspace","name":"planetscale","shards":2,"sharded":true,"created_at":"2022-01-14T15:39:28.394Z","updated_at":"2021-12-20T21:11:07.697Z"}]}` 18 | _, err := w.Write([]byte(out)) 19 | c.Assert(err, qt.IsNil) 20 | })) 21 | 22 | client, err := NewClient(WithBaseURL(ts.URL)) 23 | c.Assert(err, qt.IsNil) 24 | 25 | ctx := context.Background() 26 | 27 | keyspaces, err := client.Keyspaces.List(ctx, &ListKeyspacesRequest{ 28 | Organization: "foo", 29 | Database: "bar", 30 | Branch: "baz", 31 | }) 32 | 33 | wantID := "thisisanid" 34 | 35 | c.Assert(err, qt.IsNil) 36 | c.Assert(len(keyspaces), qt.Equals, 1) 37 | c.Assert(keyspaces[0].ID, qt.Equals, wantID) 38 | c.Assert(keyspaces[0].Sharded, qt.Equals, true) 39 | c.Assert(keyspaces[0].Shards, qt.Equals, 2) 40 | } 41 | 42 | func TestKeyspaces_Get(t *testing.T) { 43 | c := qt.New(t) 44 | 45 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 46 | w.WriteHeader(200) 47 | out := `{"type":"Keyspace","id":"thisisanid","name":"planetscale","shards":2,"sharded":true,"created_at":"2022-01-14T15:39:28.394Z","updated_at":"2021-12-20T21:11:07.697Z"}` 48 | _, err := w.Write([]byte(out)) 49 | c.Assert(err, qt.IsNil) 50 | })) 51 | 52 | client, err := NewClient(WithBaseURL(ts.URL)) 53 | c.Assert(err, qt.IsNil) 54 | 55 | ctx := context.Background() 56 | 57 | keyspace, err := client.Keyspaces.Get(ctx, &GetKeyspaceRequest{ 58 | Organization: "foo", 59 | Database: "bar", 60 | Branch: "baz", 61 | Keyspace: "qux", 62 | }) 63 | 64 | wantID := "thisisanid" 65 | 66 | c.Assert(err, qt.IsNil) 67 | c.Assert(keyspace.ID, qt.Equals, wantID) 68 | c.Assert(keyspace.Sharded, qt.Equals, true) 69 | c.Assert(keyspace.Shards, qt.Equals, 2) 70 | } 71 | 72 | func TestKeyspaces_Create(t *testing.T) { 73 | c := qt.New(t) 74 | 75 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 76 | w.WriteHeader(201) 77 | out := `{"type":"Keyspace","id":"thisisanid","name":"planetscale","shards":2,"sharded":true,"created_at":"2022-01-14T15:39:28.394Z","updated_at":"2021-12-20T21:11:07.697Z"}` 78 | _, err := w.Write([]byte(out)) 79 | c.Assert(err, qt.IsNil) 80 | c.Assert(r.Method, qt.Equals, http.MethodPost) 81 | })) 82 | 83 | client, err := NewClient(WithBaseURL(ts.URL)) 84 | c.Assert(err, qt.IsNil) 85 | 86 | ctx := context.Background() 87 | 88 | keyspace, err := client.Keyspaces.Create(ctx, &CreateKeyspaceRequest{ 89 | Organization: "foo", 90 | Database: "bar", 91 | Branch: "baz", 92 | Name: "qux", 93 | ClusterSize: "small", 94 | ExtraReplicas: 3, 95 | Shards: 2, 96 | }) 97 | 98 | wantID := "thisisanid" 99 | 100 | c.Assert(err, qt.IsNil) 101 | c.Assert(keyspace.ID, qt.Equals, wantID) 102 | c.Assert(keyspace.Sharded, qt.Equals, true) 103 | c.Assert(keyspace.Shards, qt.Equals, 2) 104 | } 105 | 106 | func TestKeyspaces_VSchema(t *testing.T) { 107 | c := qt.New(t) 108 | 109 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 110 | w.WriteHeader(200) 111 | out := `{"raw":"{\"sharded\":true,\"tables\":{}}","html":"
\"sharded\":true,\"tables\":{}
"}` 112 | _, err := w.Write([]byte(out)) 113 | c.Assert(err, qt.IsNil) 114 | })) 115 | 116 | client, err := NewClient(WithBaseURL(ts.URL)) 117 | c.Assert(err, qt.IsNil) 118 | 119 | ctx := context.Background() 120 | 121 | vSchema, err := client.Keyspaces.VSchema(ctx, &GetKeyspaceVSchemaRequest{ 122 | Organization: "foo", 123 | Database: "bar", 124 | Branch: "baz", 125 | Keyspace: "qux", 126 | }) 127 | 128 | wantRaw := "{\"sharded\":true,\"tables\":{}}" 129 | wantHTML := "
\"sharded\":true,\"tables\":{}
" 130 | 131 | c.Assert(err, qt.IsNil) 132 | c.Assert(vSchema.Raw, qt.Equals, wantRaw) 133 | c.Assert(vSchema.HTML, qt.Equals, wantHTML) 134 | } 135 | 136 | func TestKeyspaces_UpdateVSchema(t *testing.T) { 137 | c := qt.New(t) 138 | 139 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 140 | w.WriteHeader(200) 141 | out := `{"raw":"{\"sharded\":true,\"tables\":{}}","html":"
\"sharded\":true,\"tables\":{}
"}` 142 | _, err := w.Write([]byte(out)) 143 | c.Assert(err, qt.IsNil) 144 | c.Assert(r.Method, qt.Equals, http.MethodPatch) 145 | })) 146 | 147 | client, err := NewClient(WithBaseURL(ts.URL)) 148 | c.Assert(err, qt.IsNil) 149 | 150 | ctx := context.Background() 151 | 152 | vSchema, err := client.Keyspaces.UpdateVSchema(ctx, &UpdateKeyspaceVSchemaRequest{ 153 | Organization: "foo", 154 | Database: "bar", 155 | Branch: "baz", 156 | Keyspace: "qux", 157 | VSchema: "{\"sharded\":true,\"tables\":{}}", 158 | }) 159 | 160 | wantRaw := "{\"sharded\":true,\"tables\":{}}" 161 | wantHTML := "
\"sharded\":true,\"tables\":{}
" 162 | 163 | c.Assert(err, qt.IsNil) 164 | c.Assert(vSchema.Raw, qt.Equals, wantRaw) 165 | c.Assert(vSchema.HTML, qt.Equals, wantHTML) 166 | } 167 | 168 | func TestKeyspaces_Resize(t *testing.T) { 169 | c := qt.New(t) 170 | 171 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 172 | w.WriteHeader(200) 173 | out := `{"id":"thisisanid","type":"KeyspaceResizeRequest","state":"pending","started_at":"2024-06-25T18:03:09.459Z","completed_at":"2024-06-25T18:04:06.228Z","created_at":"2024-06-25T18:03:09.439Z","updated_at":"2024-06-25T18:04:06.238Z","actor":{"id":"actorid","type":"User","display_name":"Test User"},"cluster_name":"PS_10","extra_replicas":1,"previous_cluster_name":"PS_10","replicas":3,"previous_replicas":5}` 174 | _, err := w.Write([]byte(out)) 175 | c.Assert(err, qt.IsNil) 176 | c.Assert(r.Method, qt.Equals, http.MethodPut) 177 | })) 178 | 179 | client, err := NewClient(WithBaseURL(ts.URL)) 180 | c.Assert(err, qt.IsNil) 181 | 182 | ctx := context.Background() 183 | 184 | size := "PS_10" 185 | replicas := uint(3) 186 | 187 | krr, err := client.Keyspaces.Resize(ctx, &ResizeKeyspaceRequest{ 188 | Organization: "foo", 189 | Database: "bar", 190 | Branch: "baz", 191 | Keyspace: "qux", 192 | ClusterSize: &size, 193 | ExtraReplicas: &replicas, 194 | }) 195 | 196 | wantID := "thisisanid" 197 | 198 | c.Assert(err, qt.IsNil) 199 | c.Assert(krr.ID, qt.Equals, wantID) 200 | c.Assert(krr.ExtraReplicas, qt.Equals, uint(1)) 201 | c.Assert(krr.Replicas, qt.Equals, uint(3)) 202 | c.Assert(krr.PreviousReplicas, qt.Equals, uint(5)) 203 | c.Assert(krr.ClusterSize, qt.Equals, "PS_10") 204 | c.Assert(krr.PreviousClusterSize, qt.Equals, "PS_10") 205 | } 206 | 207 | func TestKeyspaces_CancelResize(t *testing.T) { 208 | c := qt.New(t) 209 | 210 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 211 | w.WriteHeader(204) 212 | c.Assert(r.Method, qt.Equals, http.MethodDelete) 213 | })) 214 | 215 | client, err := NewClient(WithBaseURL(ts.URL)) 216 | c.Assert(err, qt.IsNil) 217 | 218 | ctx := context.Background() 219 | 220 | err = client.Keyspaces.CancelResize(ctx, &CancelKeyspaceResizeRequest{ 221 | Organization: "foo", 222 | Database: "bar", 223 | Branch: "baz", 224 | Keyspace: "qux", 225 | }) 226 | 227 | c.Assert(err, qt.IsNil) 228 | } 229 | 230 | func TestKeyspaces_ResizeStatus(t *testing.T) { 231 | c := qt.New(t) 232 | 233 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 234 | w.WriteHeader(200) 235 | out := `{"type":"list","current_page":1,"next_page":null,"next_page_url":null,"prev_page":null,"prev_page_url":null,"data":[{"id":"thisisanid","type":"KeyspaceResizeRequest","state":"completed","started_at":"2024-06-25T18:03:09.459Z","completed_at":"2024-06-25T18:04:06.228Z","created_at":"2024-06-25T18:03:09.439Z","updated_at":"2024-06-25T18:04:06.238Z","actor":{"id":"thisisanid","type":"User","display_name":"Test User"},"cluster_name":"PS_10","extra_replicas":0,"previous_cluster_name":"PS_10","replicas":2,"previous_replicas":5}]}` 236 | _, err := w.Write([]byte(out)) 237 | c.Assert(err, qt.IsNil) 238 | c.Assert(r.Method, qt.Equals, http.MethodGet) 239 | })) 240 | 241 | client, err := NewClient(WithBaseURL(ts.URL)) 242 | c.Assert(err, qt.IsNil) 243 | 244 | ctx := context.Background() 245 | 246 | krr, err := client.Keyspaces.ResizeStatus(ctx, &KeyspaceResizeStatusRequest{ 247 | Organization: "foo", 248 | Database: "bar", 249 | Branch: "baz", 250 | Keyspace: "qux", 251 | }) 252 | 253 | wantID := "thisisanid" 254 | 255 | c.Assert(err, qt.IsNil) 256 | c.Assert(krr.ID, qt.Equals, wantID) 257 | c.Assert(krr.ExtraReplicas, qt.Equals, uint(0)) 258 | c.Assert(krr.Replicas, qt.Equals, uint(2)) 259 | c.Assert(krr.PreviousReplicas, qt.Equals, uint(5)) 260 | c.Assert(krr.ClusterSize, qt.Equals, "PS_10") 261 | c.Assert(krr.PreviousClusterSize, qt.Equals, "PS_10") 262 | } 263 | 264 | func TestKeyspaces_ResizeStatusEmpty(t *testing.T) { 265 | c := qt.New(t) 266 | 267 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 268 | w.WriteHeader(200) 269 | out := `{"type":"list","current_page":1,"next_page":null,"next_page_url":null,"prev_page":null,"prev_page_url":null,"data":[]}` 270 | _, err := w.Write([]byte(out)) 271 | c.Assert(err, qt.IsNil) 272 | c.Assert(r.Method, qt.Equals, http.MethodGet) 273 | })) 274 | 275 | client, err := NewClient(WithBaseURL(ts.URL)) 276 | c.Assert(err, qt.IsNil) 277 | 278 | ctx := context.Background() 279 | 280 | krr, err := client.Keyspaces.ResizeStatus(ctx, &KeyspaceResizeStatusRequest{ 281 | Organization: "foo", 282 | Database: "bar", 283 | Branch: "baz", 284 | Keyspace: "qux", 285 | }) 286 | 287 | wantError := &Error{ 288 | msg: "Not Found", 289 | Code: ErrNotFound, 290 | } 291 | 292 | c.Assert(krr, qt.IsNil) 293 | c.Assert(err.Error(), qt.Equals, wantError.Error()) 294 | } 295 | 296 | func TestKeyspaces_RolloutStatus(t *testing.T) { 297 | c := qt.New(t) 298 | 299 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 300 | w.WriteHeader(200) 301 | out := `{"type":"BranchInfrastructureKeyspace","state":"complete","name":"qux","shards":[{"type":"BranchInfrastructureKeyspaceShard","state":"complete","last_rollout_started_at":"2025-01-17T18:27:25.027Z","last_rollout_finished_at":"2025-01-17T18:28:25.027Z","name":"-80"},{"type":"BranchInfrastructureKeyspaceShard","state":"complete","last_rollout_started_at":"2025-01-17T18:28:25.033Z","last_rollout_finished_at":"2025-01-17T18:29:25.033Z","name":"80-"}]}` 302 | _, err := w.Write([]byte(out)) 303 | c.Assert(err, qt.IsNil) 304 | c.Assert(r.Method, qt.Equals, http.MethodGet) 305 | })) 306 | 307 | client, err := NewClient(WithBaseURL(ts.URL)) 308 | c.Assert(err, qt.IsNil) 309 | 310 | ctx := context.Background() 311 | 312 | krr, err := client.Keyspaces.RolloutStatus(ctx, &KeyspaceRolloutStatusRequest{ 313 | Organization: "foo", 314 | Database: "bar", 315 | Branch: "baz", 316 | Keyspace: "qux", 317 | }) 318 | 319 | c.Assert(err, qt.IsNil) 320 | c.Assert(krr.Name, qt.Equals, "qux") 321 | c.Assert(krr.State, qt.Equals, "complete") 322 | c.Assert(len(krr.Shards), qt.Equals, int(2)) 323 | c.Assert(krr.Shards[0].Name, qt.Equals, "-80") 324 | c.Assert(krr.Shards[0].State, qt.Equals, "complete") 325 | c.Assert(krr.Shards[1].Name, qt.Equals, "80-") 326 | c.Assert(krr.Shards[1].State, qt.Equals, "complete") 327 | } 328 | 329 | func TestKeyspaces_UpdateSettings(t *testing.T) { 330 | c := qt.New(t) 331 | 332 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 333 | w.WriteHeader(200) 334 | out := `{"type":"Keyspace","id":"thisisanid","name":"planetscale","shards":2,"sharded":true,"created_at":"2022-01-14T15:39:28.394Z","updated_at":"2021-12-20T21:11:07.697Z","vreplication_flags":{"optimize_inserts":true,"allow_no_blob_binlog_row_image":true,"vplayer_batching":true},"replication_durability_constraints":{"strategy":"maximum"}}` 335 | _, err := w.Write([]byte(out)) 336 | c.Assert(err, qt.IsNil) 337 | c.Assert(r.Method, qt.Equals, http.MethodPatch) 338 | })) 339 | 340 | client, err := NewClient(WithBaseURL(ts.URL)) 341 | c.Assert(err, qt.IsNil) 342 | 343 | ctx := context.Background() 344 | 345 | keyspace, err := client.Keyspaces.UpdateSettings(ctx, &UpdateKeyspaceSettingsRequest{ 346 | Organization: "foo", 347 | Database: "bar", 348 | Branch: "baz", 349 | Keyspace: "qux", 350 | VReplicationFlags: &VReplicationFlags{ 351 | OptimizeInserts: true, 352 | AllowNoBlobBinlogRowImage: true, 353 | VPlayerBatching: true, 354 | }, 355 | ReplicationDurabilityConstraints: &ReplicationDurabilityConstraints{ 356 | Strategy: "maximum", 357 | }, 358 | }) 359 | 360 | c.Assert(err, qt.IsNil) 361 | c.Assert(keyspace.ID, qt.Equals, "thisisanid") 362 | c.Assert(keyspace.Sharded, qt.Equals, true) 363 | c.Assert(keyspace.Shards, qt.Equals, 2) 364 | c.Assert(keyspace.VReplicationFlags.OptimizeInserts, qt.Equals, true) 365 | c.Assert(keyspace.VReplicationFlags.AllowNoBlobBinlogRowImage, qt.Equals, true) 366 | c.Assert(keyspace.VReplicationFlags.VPlayerBatching, qt.Equals, true) 367 | c.Assert(keyspace.ReplicationDurabilityConstraints.Strategy, qt.Equals, "maximum") 368 | } 369 | -------------------------------------------------------------------------------- /planetscale/organizations.go: -------------------------------------------------------------------------------- 1 | package planetscale 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | "time" 8 | ) 9 | 10 | const organizationsAPIPath = "v1/organizations" 11 | 12 | // GetOrganizationRequest encapsulates the request for getting a single 13 | // organization. 14 | type GetOrganizationRequest struct { 15 | Organization string 16 | } 17 | 18 | // OrganizationsService is an interface for communicating with the PlanetScale 19 | // Organizations API endpoints. 20 | type OrganizationsService interface { 21 | Get(context.Context, *GetOrganizationRequest) (*Organization, error) 22 | List(context.Context) ([]*Organization, error) 23 | ListRegions(context.Context, *ListOrganizationRegionsRequest) ([]*Region, error) 24 | ListClusterSKUs(context.Context, *ListOrganizationClusterSKUsRequest, ...ListOption) ([]*ClusterSKU, error) 25 | } 26 | 27 | // ListRegionsRequest encapsulates the request for getting a list of regions for 28 | // an organization. 29 | type ListOrganizationRegionsRequest struct { 30 | Organization string 31 | } 32 | 33 | // ListOrganizationClusterSKUsRequest encapsulates the request for getting a list of Cluster SKUs for an organization. 34 | type ListOrganizationClusterSKUsRequest struct { 35 | Organization string 36 | } 37 | 38 | // ClusterSKU represents a SKU for a PlanetScale cluster 39 | type ClusterSKU struct { 40 | Name string `json:"name"` 41 | DisplayName string `json:"display_name"` 42 | CPU string `json:"cpu"` 43 | Memory int64 `json:"ram"` 44 | 45 | SortOrder int64 `json:"sort_order"` 46 | 47 | Storage *int64 `json:"storage"` 48 | 49 | Rate *int64 `json:"rate"` 50 | ReplicaRate *int64 `json:"replica_rate"` 51 | ProviderInstanceType *string `json:"provider_instance_type"` 52 | Provider *string `json:"provider"` 53 | Enabled bool `json:"enabled"` 54 | DefaultVTGate string `json:"default_vtgate"` 55 | DefaultVTGateRate *int64 `json:"default_vtgate_rate"` 56 | 57 | Metal bool `json:"metal"` 58 | } 59 | 60 | // Organization represents a PlanetScale organization. 61 | type Organization struct { 62 | Name string `json:"name"` 63 | CreatedAt time.Time `json:"created_at"` 64 | UpdatedAt time.Time `json:"updated_at"` 65 | RemainingFreeDatabases int `json:"free_databases_remaining"` 66 | } 67 | 68 | type organizationsResponse struct { 69 | Organizations []*Organization `json:"data"` 70 | } 71 | 72 | type organizationsService struct { 73 | client *Client 74 | } 75 | 76 | var _ OrganizationsService = &organizationsService{} 77 | 78 | func NewOrganizationsService(client *Client) *organizationsService { 79 | return &organizationsService{ 80 | client: client, 81 | } 82 | } 83 | 84 | // Get fetches a single organization by name. 85 | func (o *organizationsService) Get(ctx context.Context, getReq *GetOrganizationRequest) (*Organization, error) { 86 | req, err := o.client.newRequest(http.MethodGet, fmt.Sprintf("%s/%s", organizationsAPIPath, getReq.Organization), nil) 87 | if err != nil { 88 | return nil, fmt.Errorf("error creating request for get organization: %w", err) 89 | } 90 | 91 | org := &Organization{} 92 | if err := o.client.do(ctx, req, &org); err != nil { 93 | return nil, err 94 | } 95 | 96 | return org, nil 97 | } 98 | 99 | // List returns all the organizations for a user. 100 | func (o *organizationsService) List(ctx context.Context) ([]*Organization, error) { 101 | req, err := o.client.newRequest(http.MethodGet, organizationsAPIPath, nil) 102 | if err != nil { 103 | return nil, fmt.Errorf("error creating request for list organization: %w", err) 104 | } 105 | 106 | orgResponse := &organizationsResponse{} 107 | if err := o.client.do(ctx, req, &orgResponse); err != nil { 108 | return nil, err 109 | } 110 | 111 | return orgResponse.Organizations, nil 112 | } 113 | 114 | type listRegionsResponse struct { 115 | Regions []*Region `json:"data"` 116 | } 117 | 118 | func (o *organizationsService) ListRegions(ctx context.Context, listReq *ListOrganizationRegionsRequest) ([]*Region, error) { 119 | req, err := o.client.newRequest(http.MethodGet, fmt.Sprintf("%s/%s/regions", organizationsAPIPath, listReq.Organization), nil) 120 | if err != nil { 121 | return nil, err 122 | } 123 | 124 | listResponse := &listRegionsResponse{} 125 | if err := o.client.do(ctx, req, &listResponse); err != nil { 126 | return nil, err 127 | } 128 | 129 | return listResponse.Regions, nil 130 | } 131 | 132 | func (o *organizationsService) ListClusterSKUs(ctx context.Context, listReq *ListOrganizationClusterSKUsRequest, opts ...ListOption) ([]*ClusterSKU, error) { 133 | path := fmt.Sprintf("%s/%s/cluster-size-skus", organizationsAPIPath, listReq.Organization) 134 | 135 | defaultOpts := defaultListOptions() 136 | for _, opt := range opts { 137 | err := opt(defaultOpts) 138 | if err != nil { 139 | return nil, err 140 | } 141 | } 142 | 143 | if vals := defaultOpts.URLValues.Encode(); vals != "" { 144 | path += "?" + vals 145 | } 146 | 147 | req, err := o.client.newRequest(http.MethodGet, path, nil) 148 | if err != nil { 149 | return nil, fmt.Errorf("error creating http request: %w", err) 150 | } 151 | 152 | clusterSKUs := []*ClusterSKU{} 153 | if err := o.client.do(ctx, req, &clusterSKUs); err != nil { 154 | return nil, err 155 | } 156 | 157 | return clusterSKUs, nil 158 | } 159 | -------------------------------------------------------------------------------- /planetscale/organizations_test.go: -------------------------------------------------------------------------------- 1 | package planetscale 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "net/http/httptest" 7 | "testing" 8 | "time" 9 | 10 | qt "github.com/frankban/quicktest" 11 | ) 12 | 13 | func TestOrganizations_List(t *testing.T) { 14 | c := qt.New(t) 15 | 16 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 17 | w.WriteHeader(200) 18 | out := `{ 19 | "data": [ 20 | { 21 | "id": "my-cool-org", 22 | "type": "organization", 23 | "name": "my-cool-org", 24 | "created_at": "2021-01-14T10:19:23.000Z", 25 | "updated_at": "2021-01-14T10:19:23.000Z" 26 | } 27 | ] 28 | }` 29 | 30 | _, err := w.Write([]byte(out)) 31 | c.Assert(err, qt.IsNil) 32 | })) 33 | 34 | client, err := NewClient(WithBaseURL(ts.URL)) 35 | c.Assert(err, qt.IsNil) 36 | 37 | ctx := context.Background() 38 | 39 | orgs, err := client.Organizations.List(ctx) 40 | 41 | c.Assert(err, qt.IsNil) 42 | want := []*Organization{ 43 | { 44 | Name: "my-cool-org", 45 | CreatedAt: time.Date(2021, time.January, 14, 10, 19, 23, 0, time.UTC), 46 | UpdatedAt: time.Date(2021, time.January, 14, 10, 19, 23, 0, time.UTC), 47 | }, 48 | } 49 | 50 | c.Assert(orgs, qt.DeepEquals, want) 51 | } 52 | 53 | func TestOrganizations_Get(t *testing.T) { 54 | c := qt.New(t) 55 | 56 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 57 | w.WriteHeader(200) 58 | out := `{ 59 | "id": "my-cool-org", 60 | "type": "organization", 61 | "name": "my-cool-org", 62 | "created_at": "2021-01-14T10:19:23.000Z", 63 | "updated_at": "2021-01-14T10:19:23.000Z" 64 | }` 65 | 66 | _, err := w.Write([]byte(out)) 67 | c.Assert(err, qt.IsNil) 68 | })) 69 | 70 | client, err := NewClient(WithBaseURL(ts.URL)) 71 | c.Assert(err, qt.IsNil) 72 | 73 | ctx := context.Background() 74 | 75 | org, err := client.Organizations.Get(ctx, &GetOrganizationRequest{ 76 | Organization: "my-cool-org", 77 | }) 78 | 79 | c.Assert(err, qt.IsNil) 80 | want := &Organization{ 81 | Name: "my-cool-org", 82 | CreatedAt: time.Date(2021, time.January, 14, 10, 19, 23, 0, time.UTC), 83 | UpdatedAt: time.Date(2021, time.January, 14, 10, 19, 23, 0, time.UTC), 84 | } 85 | 86 | c.Assert(org, qt.DeepEquals, want) 87 | } 88 | 89 | func TestOrganizations_ListRegions(t *testing.T) { 90 | c := qt.New(t) 91 | 92 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 93 | w.WriteHeader(200) 94 | out := `{ 95 | "data": [ 96 | { 97 | "id": "my-cool-org", 98 | "type": "Region", 99 | "slug": "us-east", 100 | "display_name": "US East", 101 | "enabled": true 102 | } 103 | ] 104 | }` 105 | 106 | _, err := w.Write([]byte(out)) 107 | c.Assert(err, qt.IsNil) 108 | })) 109 | 110 | client, err := NewClient(WithBaseURL(ts.URL)) 111 | c.Assert(err, qt.IsNil) 112 | 113 | ctx := context.Background() 114 | 115 | orgs, err := client.Organizations.ListRegions(ctx, &ListOrganizationRegionsRequest{ 116 | Organization: "my-cool-org", 117 | }) 118 | 119 | c.Assert(err, qt.IsNil) 120 | want := []*Region{ 121 | { 122 | Name: "US East", 123 | Slug: "us-east", 124 | Enabled: true, 125 | }, 126 | } 127 | 128 | c.Assert(orgs, qt.DeepEquals, want) 129 | } 130 | 131 | func TestOrganizations_ListClusterSKUs(t *testing.T) { 132 | c := qt.New(t) 133 | 134 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 135 | w.WriteHeader(200) 136 | 137 | c.Assert(r.URL.String(), qt.Equals, "/v1/organizations/my-cool-org/cluster-size-skus") 138 | out := `[ 139 | { 140 | "name": "PS_10", 141 | "type": "ClusterSizeSku", 142 | "display_name": "PS-10", 143 | "cpu": "1/8", 144 | "provider_instance_type": null, 145 | "storage": null, 146 | "ram": 1, 147 | "metal": true, 148 | "enabled": true, 149 | "provider": null, 150 | "rate": null, 151 | "replica_rate": null, 152 | "default_vtgate": "VTG_5", 153 | "default_vtgate_rate": null, 154 | "sort_order": 1 155 | } 156 | ]` 157 | 158 | _, err := w.Write([]byte(out)) 159 | c.Assert(err, qt.IsNil) 160 | })) 161 | 162 | client, err := NewClient(WithBaseURL(ts.URL)) 163 | c.Assert(err, qt.IsNil) 164 | 165 | ctx := context.Background() 166 | 167 | orgs, err := client.Organizations.ListClusterSKUs(ctx, &ListOrganizationClusterSKUsRequest{ 168 | Organization: "my-cool-org", 169 | }) 170 | 171 | c.Assert(err, qt.IsNil) 172 | want := []*ClusterSKU{ 173 | { 174 | Name: "PS_10", 175 | DisplayName: "PS-10", 176 | CPU: "1/8", 177 | Memory: 1, 178 | Enabled: true, 179 | DefaultVTGate: "VTG_5", 180 | SortOrder: 1, 181 | Metal: true, 182 | }, 183 | } 184 | 185 | c.Assert(orgs, qt.DeepEquals, want) 186 | } 187 | 188 | func TestOrganizations_ListClusterSKUsWithRates(t *testing.T) { 189 | c := qt.New(t) 190 | 191 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 192 | w.WriteHeader(200) 193 | 194 | c.Assert(r.URL.String(), qt.Equals, "/v1/organizations/my-cool-org/cluster-size-skus?rates=true") 195 | out := `[ 196 | { 197 | "name": "PS_10", 198 | "type": "ClusterSizeSku", 199 | "display_name": "PS-10", 200 | "cpu": "1/8", 201 | "provider_instance_type": null, 202 | "storage": 100, 203 | "ram": 1, 204 | "sort_order": 1, 205 | "enabled": true, 206 | "provider": null, 207 | "rate": 39, 208 | "replica_rate": 13, 209 | "default_vtgate": "VTG_5", 210 | "default_vtgate_rate": null 211 | } 212 | ]` 213 | 214 | _, err := w.Write([]byte(out)) 215 | c.Assert(err, qt.IsNil) 216 | })) 217 | 218 | client, err := NewClient(WithBaseURL(ts.URL)) 219 | c.Assert(err, qt.IsNil) 220 | 221 | ctx := context.Background() 222 | 223 | orgs, err := client.Organizations.ListClusterSKUs(ctx, &ListOrganizationClusterSKUsRequest{ 224 | Organization: "my-cool-org", 225 | }, WithRates()) 226 | 227 | c.Assert(err, qt.IsNil) 228 | want := []*ClusterSKU{ 229 | { 230 | Name: "PS_10", 231 | DisplayName: "PS-10", 232 | CPU: "1/8", 233 | Memory: 1, 234 | Enabled: true, 235 | Storage: Pointer[int64](100), 236 | Rate: Pointer[int64](39), 237 | ReplicaRate: Pointer[int64](13), 238 | DefaultVTGate: "VTG_5", 239 | SortOrder: 1, 240 | }, 241 | } 242 | 243 | c.Assert(orgs, qt.DeepEquals, want) 244 | } 245 | -------------------------------------------------------------------------------- /planetscale/passwords.go: -------------------------------------------------------------------------------- 1 | package planetscale 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | "time" 8 | ) 9 | 10 | type DatabaseBranchPassword struct { 11 | PublicID string `json:"id"` 12 | Name string `json:"name"` 13 | Hostname string `json:"access_host_url"` 14 | Username string `json:"username"` 15 | Role string `json:"role"` 16 | Actor *Actor `json:"actor"` 17 | Branch DatabaseBranch `json:"database_branch"` 18 | CreatedAt time.Time `json:"created_at"` 19 | DeletedAt time.Time `json:"deleted_at"` 20 | ExpiresAt time.Time `json:"expires_at"` 21 | PlainText string `json:"plain_text"` 22 | TTL int `json:"ttl_seconds"` 23 | Renewable bool `json:"renewable"` 24 | Replica bool `json:"replica"` 25 | } 26 | 27 | // DatabaseBranchPasswordRequest encapsulates the request for creating/getting/deleting a 28 | // database branch password. 29 | type DatabaseBranchPasswordRequest struct { 30 | Organization string `json:"-"` 31 | Database string `json:"-"` 32 | Branch string `json:"-"` 33 | Role string `json:"role,omitempty"` 34 | Name string `json:"name"` 35 | TTL int `json:"ttl,omitempty"` 36 | Replica bool `json:"replica,omitempty"` 37 | } 38 | 39 | // ListDatabaseBranchPasswordRequest encapsulates the request for listing all passwords 40 | // for a given database branch. 41 | type ListDatabaseBranchPasswordRequest struct { 42 | Organization string 43 | Database string 44 | Branch string 45 | } 46 | 47 | // GetDatabaseBranchPasswordRequest encapsulates the request for listing all passwords 48 | // for a given database branch. 49 | type GetDatabaseBranchPasswordRequest struct { 50 | Organization string `json:"-"` 51 | Database string `json:"-"` 52 | Branch string `json:"-"` 53 | Name string `json:"name"` 54 | PasswordId string 55 | } 56 | 57 | // DeleteDatabaseBranchPasswordRequest encapsulates the request for deleting a password 58 | // for a given database branch. 59 | type DeleteDatabaseBranchPasswordRequest struct { 60 | Organization string `json:"-"` 61 | Database string `json:"-"` 62 | Branch string `json:"-"` 63 | Name string `json:"name"` 64 | PasswordId string 65 | } 66 | 67 | // RenewDatabaseBranchPasswordRequest encapsulates the request for renewing a password 68 | // for a given database branch. 69 | type RenewDatabaseBranchPasswordRequest struct { 70 | Organization string 71 | Database string 72 | Branch string 73 | PasswordId string 74 | } 75 | 76 | // DatabaseBranchPasswordsService is an interface for communicating with the PlanetScale 77 | // Database Branch Passwords API endpoint. 78 | type PasswordsService interface { 79 | Create(context.Context, *DatabaseBranchPasswordRequest) (*DatabaseBranchPassword, error) 80 | // List returns passwords with optional pagination support via ListOption parameters 81 | List(context.Context, *ListDatabaseBranchPasswordRequest, ...ListOption) ([]*DatabaseBranchPassword, error) 82 | Get(context.Context, *GetDatabaseBranchPasswordRequest) (*DatabaseBranchPassword, error) 83 | Delete(context.Context, *DeleteDatabaseBranchPasswordRequest) error 84 | Renew(context.Context, *RenewDatabaseBranchPasswordRequest) (*DatabaseBranchPassword, error) 85 | } 86 | 87 | type passwordsService struct { 88 | client *Client 89 | } 90 | 91 | type passwordsResponse struct { 92 | Passwords []*DatabaseBranchPassword `json:"data"` 93 | } 94 | 95 | var _ PasswordsService = &passwordsService{} 96 | 97 | func NewPasswordsService(client *Client) *passwordsService { 98 | return &passwordsService{ 99 | client: client, 100 | } 101 | } 102 | 103 | // Creates a new password for a branch. 104 | func (d *passwordsService) Create(ctx context.Context, createReq *DatabaseBranchPasswordRequest) (*DatabaseBranchPassword, error) { 105 | path := passwordsBranchAPIPath(createReq.Organization, createReq.Database, createReq.Branch) 106 | req, err := d.client.newRequest(http.MethodPost, path, createReq) 107 | if err != nil { 108 | return nil, fmt.Errorf("error creating http request: %w", err) 109 | } 110 | 111 | password := &DatabaseBranchPassword{} 112 | if err := d.client.do(ctx, req, &password); err != nil { 113 | return nil, err 114 | } 115 | 116 | return password, nil 117 | } 118 | 119 | // Delete an existing password for a branch. 120 | func (d *passwordsService) Delete(ctx context.Context, deleteReq *DeleteDatabaseBranchPasswordRequest) error { 121 | path := passwordBranchAPIPath(deleteReq.Organization, deleteReq.Database, deleteReq.Branch, deleteReq.PasswordId) 122 | req, err := d.client.newRequest(http.MethodDelete, path, nil) 123 | if err != nil { 124 | return fmt.Errorf("error creating http request: %w", err) 125 | } 126 | 127 | err = d.client.do(ctx, req, nil) 128 | return err 129 | } 130 | 131 | // Get an existing password for a branch. 132 | func (d *passwordsService) Get(ctx context.Context, getReq *GetDatabaseBranchPasswordRequest) (*DatabaseBranchPassword, error) { 133 | path := passwordBranchAPIPath(getReq.Organization, getReq.Database, getReq.Branch, getReq.PasswordId) 134 | req, err := d.client.newRequest(http.MethodGet, path, nil) 135 | if err != nil { 136 | return nil, fmt.Errorf("error creating http request: %w", err) 137 | } 138 | 139 | password := &DatabaseBranchPassword{} 140 | if err := d.client.do(ctx, req, &password); err != nil { 141 | return nil, err 142 | } 143 | 144 | return password, nil 145 | } 146 | 147 | // List all existing passwords. If req.Branch is set, all passwords for that 148 | // branch will be listed. 149 | func (d *passwordsService) List(ctx context.Context, listReq *ListDatabaseBranchPasswordRequest, opts ...ListOption) ([]*DatabaseBranchPassword, error) { 150 | path := passwordsAPIPath(listReq.Organization, listReq.Database) 151 | if listReq.Branch != "" { 152 | path = passwordBranchAPIPath(listReq.Organization, listReq.Database, listReq.Branch, "") 153 | } 154 | 155 | defaultOpts := defaultListOptions(WithPerPage(50)) 156 | for _, opt := range opts { 157 | err := opt(defaultOpts) 158 | if err != nil { 159 | return nil, err 160 | } 161 | } 162 | 163 | if vals := defaultOpts.URLValues.Encode(); vals != "" { 164 | path += "?" + vals 165 | } 166 | 167 | req, err := d.client.newRequest(http.MethodGet, path, nil) 168 | if err != nil { 169 | return nil, fmt.Errorf("error creating http request to list passwords: %w", err) 170 | } 171 | 172 | passwordsResp := &passwordsResponse{} 173 | if err := d.client.do(ctx, req, &passwordsResp); err != nil { 174 | return nil, err 175 | } 176 | 177 | return passwordsResp.Passwords, nil 178 | } 179 | 180 | func (d *passwordsService) Renew(ctx context.Context, renewReq *RenewDatabaseBranchPasswordRequest) (*DatabaseBranchPassword, error) { 181 | path := passwordRenewAPIPath(renewReq.Organization, renewReq.Database, renewReq.Branch, renewReq.PasswordId) 182 | req, err := d.client.newRequest(http.MethodPost, path, nil) 183 | if err != nil { 184 | return nil, fmt.Errorf("error creating http request: %w", err) 185 | } 186 | 187 | password := &DatabaseBranchPassword{} 188 | if err := d.client.do(ctx, req, &password); err != nil { 189 | return nil, err 190 | } 191 | return password, nil 192 | } 193 | 194 | func passwordBranchAPIPath(org, db, branch, password string) string { 195 | return fmt.Sprintf("%s/%s", passwordsBranchAPIPath(org, db, branch), password) 196 | } 197 | 198 | func passwordsBranchAPIPath(org, db, branch string) string { 199 | return fmt.Sprintf("%s/passwords", databaseBranchAPIPath(org, db, branch)) 200 | } 201 | 202 | func passwordsAPIPath(org, db string) string { 203 | return fmt.Sprintf("%s/%s/passwords", databasesAPIPath(org), db) 204 | } 205 | 206 | func passwordRenewAPIPath(org, db, branch, password string) string { 207 | return fmt.Sprintf("%s/renew", passwordBranchAPIPath(org, db, branch, password)) 208 | } 209 | -------------------------------------------------------------------------------- /planetscale/passwords_test.go: -------------------------------------------------------------------------------- 1 | package planetscale 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | "net/http/httptest" 8 | "testing" 9 | "time" 10 | 11 | qt "github.com/frankban/quicktest" 12 | ) 13 | 14 | const testPasswordID = "4rwwvrxk2o99" // #nosec G101 - Not a password but a password identifier. 15 | 16 | func TestPasswords_Create(t *testing.T) { 17 | c := qt.New(t) 18 | plainText := "plain-text-password" 19 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 20 | w.WriteHeader(200) 21 | out := fmt.Sprintf(`{ 22 | "id": "%s", 23 | "role": "admin", 24 | "plain_text": "%s", 25 | "name": "planetscale-go-test-password", 26 | "created_at": "2021-01-14T10:19:23.000Z", 27 | "replica": false 28 | }`, testPasswordID, plainText) 29 | _, err := w.Write([]byte(out)) 30 | c.Assert(err, qt.IsNil) 31 | })) 32 | 33 | client, err := NewClient(WithBaseURL(ts.URL)) 34 | c.Assert(err, qt.IsNil) 35 | 36 | ctx := context.Background() 37 | org := "my-org" 38 | db := "my-db" 39 | branch := "my-branch" 40 | 41 | password, err := client.Passwords.Create(ctx, &DatabaseBranchPasswordRequest{ 42 | Organization: org, 43 | Database: db, 44 | Branch: branch, 45 | Role: "admin", 46 | }) 47 | 48 | want := &DatabaseBranchPassword{ 49 | Name: "planetscale-go-test-password", 50 | PublicID: testPasswordID, 51 | 52 | CreatedAt: time.Date(2021, time.January, 14, 10, 19, 23, 0, time.UTC), 53 | Role: "admin", 54 | PlainText: plainText, 55 | Replica: false, 56 | } 57 | 58 | c.Assert(err, qt.IsNil) 59 | c.Assert(password, qt.DeepEquals, want) 60 | } 61 | 62 | func TestPasswords_CreateReplica(t *testing.T) { 63 | c := qt.New(t) 64 | plainText := "plain-text-replica-password" 65 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 66 | w.WriteHeader(200) 67 | out := fmt.Sprintf(`{ 68 | "id": "%s", 69 | "role": "reader", 70 | "plain_text": "%s", 71 | "name": "planetscale-go-test-replica-password", 72 | "created_at": "2021-01-14T10:19:23.000Z", 73 | "replica": true 74 | }`, testPasswordID, plainText) 75 | _, err := w.Write([]byte(out)) 76 | c.Assert(err, qt.IsNil) 77 | })) 78 | 79 | client, err := NewClient(WithBaseURL(ts.URL)) 80 | c.Assert(err, qt.IsNil) 81 | 82 | ctx := context.Background() 83 | org := "my-org" 84 | db := "my-db" 85 | branch := "my-branch" 86 | 87 | password, err := client.Passwords.Create(ctx, &DatabaseBranchPasswordRequest{ 88 | Organization: org, 89 | Database: db, 90 | Branch: branch, 91 | Role: "reader", 92 | Replica: true, 93 | }) 94 | 95 | want := &DatabaseBranchPassword{ 96 | Name: "planetscale-go-test-replica-password", 97 | PublicID: testPasswordID, 98 | 99 | CreatedAt: time.Date(2021, time.January, 14, 10, 19, 23, 0, time.UTC), 100 | Role: "reader", 101 | PlainText: plainText, 102 | Replica: true, 103 | } 104 | 105 | c.Assert(err, qt.IsNil) 106 | c.Assert(password, qt.DeepEquals, want) 107 | } 108 | 109 | func TestPasswords_List(t *testing.T) { 110 | c := qt.New(t) 111 | 112 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 113 | w.WriteHeader(200) 114 | out := `{ 115 | "data": 116 | [ 117 | { 118 | "id": "4rwwvrxk2o99", 119 | "name": "planetscale-go-test-password", 120 | "created_at": "2021-01-14T10:19:23.000Z" 121 | } 122 | ] 123 | }` 124 | _, err := w.Write([]byte(out)) 125 | c.Assert(err, qt.IsNil) 126 | })) 127 | 128 | client, err := NewClient(WithBaseURL(ts.URL)) 129 | c.Assert(err, qt.IsNil) 130 | 131 | ctx := context.Background() 132 | org := "my-org" 133 | db := "planetscale-go-test-db" 134 | 135 | passwords, err := client.Passwords.List(ctx, &ListDatabaseBranchPasswordRequest{ 136 | Organization: org, 137 | Database: db, 138 | }) 139 | 140 | want := []*DatabaseBranchPassword{ 141 | { 142 | Name: "planetscale-go-test-password", 143 | PublicID: testPasswordID, 144 | CreatedAt: time.Date(2021, time.January, 14, 10, 19, 23, 0, time.UTC), 145 | }, 146 | } 147 | 148 | c.Assert(err, qt.IsNil) 149 | c.Assert(passwords, qt.DeepEquals, want) 150 | } 151 | 152 | func TestPasswords_ListBranch(t *testing.T) { 153 | c := qt.New(t) 154 | 155 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 156 | w.WriteHeader(200) 157 | out := `{ 158 | "data": 159 | [ 160 | { 161 | "id": "4rwwvrxk2o99", 162 | "name": "planetscale-go-test-password", 163 | "database_branch": { 164 | "name": "my-branch" 165 | }, 166 | "created_at": "2021-01-14T10:19:23.000Z" 167 | } 168 | ] 169 | }` 170 | _, err := w.Write([]byte(out)) 171 | c.Assert(err, qt.IsNil) 172 | })) 173 | 174 | client, err := NewClient(WithBaseURL(ts.URL)) 175 | c.Assert(err, qt.IsNil) 176 | 177 | ctx := context.Background() 178 | org := "my-org" 179 | db := "planetscale-go-test-db" 180 | branch := "my-branch" 181 | 182 | passwords, err := client.Passwords.List(ctx, &ListDatabaseBranchPasswordRequest{ 183 | Organization: org, 184 | Database: db, 185 | Branch: branch, 186 | }) 187 | 188 | want := []*DatabaseBranchPassword{ 189 | { 190 | Name: "planetscale-go-test-password", 191 | Branch: DatabaseBranch{ 192 | Name: branch, 193 | }, 194 | PublicID: testPasswordID, 195 | CreatedAt: time.Date(2021, time.January, 14, 10, 19, 23, 0, time.UTC), 196 | }, 197 | } 198 | 199 | c.Assert(err, qt.IsNil) 200 | c.Assert(passwords, qt.DeepEquals, want) 201 | } 202 | 203 | func TestPasswords_ListEmpty(t *testing.T) { 204 | c := qt.New(t) 205 | 206 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 207 | w.WriteHeader(200) 208 | out := `{"data":[]}` 209 | _, err := w.Write([]byte(out)) 210 | c.Assert(err, qt.IsNil) 211 | })) 212 | 213 | client, err := NewClient(WithBaseURL(ts.URL)) 214 | c.Assert(err, qt.IsNil) 215 | 216 | ctx := context.Background() 217 | org := "my-org" 218 | db := "planetscale-go-test-db" 219 | 220 | passwords, err := client.Passwords.List(ctx, &ListDatabaseBranchPasswordRequest{ 221 | Organization: org, 222 | Database: db, 223 | }) 224 | 225 | c.Assert(err, qt.IsNil) 226 | c.Assert(passwords, qt.HasLen, 0) 227 | } 228 | 229 | func TestPasswords_ListWithPagination(t *testing.T) { 230 | c := qt.New(t) 231 | 232 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 233 | // Verify pagination parameters are included in the request 234 | c.Assert(r.URL.Query().Get("page"), qt.Equals, "2") 235 | c.Assert(r.URL.Query().Get("per_page"), qt.Equals, "50") 236 | 237 | w.WriteHeader(200) 238 | out := `{ 239 | "data": 240 | [ 241 | { 242 | "id": "4rwwvrxk2o99", 243 | "name": "planetscale-go-test-password", 244 | "created_at": "2021-01-14T10:19:23.000Z" 245 | } 246 | ] 247 | }` 248 | _, err := w.Write([]byte(out)) 249 | c.Assert(err, qt.IsNil) 250 | })) 251 | 252 | client, err := NewClient(WithBaseURL(ts.URL)) 253 | c.Assert(err, qt.IsNil) 254 | 255 | ctx := context.Background() 256 | org := "my-org" 257 | db := "planetscale-go-test-db" 258 | 259 | passwords, err := client.Passwords.List(ctx, &ListDatabaseBranchPasswordRequest{ 260 | Organization: org, 261 | Database: db, 262 | }, WithPage(2), WithPerPage(50)) 263 | 264 | want := []*DatabaseBranchPassword{ 265 | { 266 | Name: "planetscale-go-test-password", 267 | PublicID: testPasswordID, 268 | CreatedAt: time.Date(2021, time.January, 14, 10, 19, 23, 0, time.UTC), 269 | }, 270 | } 271 | 272 | c.Assert(err, qt.IsNil) 273 | c.Assert(passwords, qt.DeepEquals, want) 274 | } 275 | 276 | func TestPasswords_Get(t *testing.T) { 277 | c := qt.New(t) 278 | 279 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 280 | w.WriteHeader(200) 281 | out := fmt.Sprintf(`{ 282 | "id": "%s", 283 | "role": "writer", 284 | "name": "planetscale-go-test-password", 285 | "created_at": "2021-01-14T10:19:23.000Z" 286 | }`, testPasswordID) 287 | _, err := w.Write([]byte(out)) 288 | c.Assert(err, qt.IsNil) 289 | })) 290 | 291 | client, err := NewClient(WithBaseURL(ts.URL)) 292 | c.Assert(err, qt.IsNil) 293 | 294 | ctx := context.Background() 295 | org := "my-org" 296 | db := "planetscale-go-test-db" 297 | branch := "my-branch" 298 | 299 | password, err := client.Passwords.Get(ctx, &GetDatabaseBranchPasswordRequest{ 300 | Organization: org, 301 | Database: db, 302 | Branch: branch, 303 | PasswordId: testPasswordID, 304 | }) 305 | 306 | want := &DatabaseBranchPassword{ 307 | Name: "planetscale-go-test-password", 308 | PublicID: testPasswordID, 309 | CreatedAt: time.Date(2021, time.January, 14, 10, 19, 23, 0, time.UTC), 310 | Role: "writer", 311 | } 312 | 313 | c.Assert(err, qt.IsNil) 314 | c.Assert(password, qt.DeepEquals, want) 315 | } 316 | 317 | func TestPasswords_Renew(t *testing.T) { 318 | c := qt.New(t) 319 | 320 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 321 | w.WriteHeader(200) 322 | out := fmt.Sprintf(`{ 323 | "id": "%s", 324 | "role": "writer", 325 | "name": "planetscale-go-test-password", 326 | "created_at": "2021-01-14T10:19:23.000Z" 327 | }`, testPasswordID) 328 | _, err := w.Write([]byte(out)) 329 | c.Assert(err, qt.IsNil) 330 | })) 331 | 332 | client, err := NewClient(WithBaseURL(ts.URL)) 333 | c.Assert(err, qt.IsNil) 334 | 335 | ctx := context.Background() 336 | org := "my-org" 337 | db := "planetscale-go-test-db" 338 | branch := "my-branch" 339 | 340 | password, err := client.Passwords.Renew(ctx, &RenewDatabaseBranchPasswordRequest{ 341 | Organization: org, 342 | Database: db, 343 | Branch: branch, 344 | PasswordId: testPasswordID, 345 | }) 346 | 347 | want := &DatabaseBranchPassword{ 348 | Name: "planetscale-go-test-password", 349 | PublicID: testPasswordID, 350 | CreatedAt: time.Date(2021, time.January, 14, 10, 19, 23, 0, time.UTC), 351 | Role: "writer", 352 | } 353 | 354 | c.Assert(err, qt.IsNil) 355 | c.Assert(password, qt.DeepEquals, want) 356 | } 357 | -------------------------------------------------------------------------------- /planetscale/regions.go: -------------------------------------------------------------------------------- 1 | package planetscale 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | ) 8 | 9 | const regionsAPIPath = "v1/regions" 10 | 11 | type Region struct { 12 | Slug string `json:"slug"` 13 | Provider string `json:"provider"` 14 | Name string `json:"display_name"` 15 | Location string `json:"location"` 16 | Enabled bool `json:"enabled"` 17 | IsDefault bool `json:"current_default"` 18 | } 19 | 20 | type regionsResponse struct { 21 | Regions []*Region `json:"data"` 22 | } 23 | 24 | type ListRegionsRequest struct{} 25 | 26 | type RegionsService interface { 27 | List(ctx context.Context, req *ListRegionsRequest) ([]*Region, error) 28 | } 29 | 30 | type regionsService struct { 31 | client *Client 32 | } 33 | 34 | var _ RegionsService = ®ionsService{} 35 | 36 | func NewRegionsSevice(client *Client) *regionsService { 37 | return ®ionsService{ 38 | client: client, 39 | } 40 | } 41 | 42 | func (r *regionsService) List(ctx context.Context, listReq *ListRegionsRequest) ([]*Region, error) { 43 | req, err := r.client.newRequest(http.MethodGet, regionsAPIPath, nil) 44 | if err != nil { 45 | return nil, fmt.Errorf("error creating request for list regions: %w", err) 46 | } 47 | 48 | regionsResponse := ®ionsResponse{} 49 | if err := r.client.do(ctx, req, ®ionsResponse); err != nil { 50 | return nil, err 51 | } 52 | 53 | return regionsResponse.Regions, nil 54 | } 55 | -------------------------------------------------------------------------------- /planetscale/regions_test.go: -------------------------------------------------------------------------------- 1 | package planetscale 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "net/http/httptest" 7 | "testing" 8 | 9 | qt "github.com/frankban/quicktest" 10 | ) 11 | 12 | func TestRegions_List(t *testing.T) { 13 | c := qt.New(t) 14 | 15 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 16 | w.WriteHeader(200) 17 | out := `{ 18 | "data": [ 19 | { 20 | "id": "my-cool-org", 21 | "type": "Region", 22 | "slug": "us-east", 23 | "display_name": "US East", 24 | "location": "Northern Virginia", 25 | "provider": "AWS", 26 | "enabled": true, 27 | "current_default": true 28 | } 29 | ] 30 | }` 31 | 32 | _, err := w.Write([]byte(out)) 33 | c.Assert(err, qt.IsNil) 34 | })) 35 | 36 | client, err := NewClient(WithBaseURL(ts.URL)) 37 | c.Assert(err, qt.IsNil) 38 | 39 | ctx := context.Background() 40 | 41 | orgs, err := client.Regions.List(ctx, &ListRegionsRequest{}) 42 | 43 | c.Assert(err, qt.IsNil) 44 | want := []*Region{ 45 | { 46 | Slug: "us-east", 47 | Provider: "AWS", 48 | Name: "US East", 49 | Location: "Northern Virginia", 50 | Enabled: true, 51 | IsDefault: true, 52 | }, 53 | } 54 | 55 | c.Assert(orgs, qt.DeepEquals, want) 56 | } 57 | -------------------------------------------------------------------------------- /planetscale/service_tokens.go: -------------------------------------------------------------------------------- 1 | package planetscale 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | ) 8 | 9 | var _ ServiceTokenService = &serviceTokenService{} 10 | 11 | // ServiceTokenService is an interface for communicating with the PlanetScale 12 | // Service Token API. 13 | type ServiceTokenService interface { 14 | Create(context.Context, *CreateServiceTokenRequest) (*ServiceToken, error) 15 | List(context.Context, *ListServiceTokensRequest) ([]*ServiceToken, error) 16 | ListGrants(context.Context, *ListServiceTokenGrantsRequest) ([]*ServiceTokenGrant, error) 17 | Delete(context.Context, *DeleteServiceTokenRequest) error 18 | GetAccess(context.Context, *GetServiceTokenAccessRequest) ([]*ServiceTokenAccess, error) 19 | AddAccess(context.Context, *AddServiceTokenAccessRequest) ([]*ServiceTokenAccess, error) 20 | DeleteAccess(context.Context, *DeleteServiceTokenAccessRequest) error 21 | } 22 | 23 | type serviceTokenService struct { 24 | client *Client 25 | } 26 | 27 | func (s *serviceTokenService) Create(ctx context.Context, createReq *CreateServiceTokenRequest) (*ServiceToken, error) { 28 | req, err := s.client.newRequest(http.MethodPost, serviceTokensAPIPath(createReq.Organization), nil) 29 | if err != nil { 30 | return nil, err 31 | } 32 | 33 | st := &ServiceToken{} 34 | if err := s.client.do(ctx, req, &st); err != nil { 35 | return nil, err 36 | } 37 | 38 | return st, nil 39 | } 40 | 41 | func (s *serviceTokenService) List(ctx context.Context, listReq *ListServiceTokensRequest) ([]*ServiceToken, error) { 42 | req, err := s.client.newRequest(http.MethodGet, serviceTokensAPIPath(listReq.Organization), nil) 43 | if err != nil { 44 | return nil, err 45 | } 46 | 47 | tokenListResponse := serviceTokensResponse{} 48 | if err := s.client.do(ctx, req, &tokenListResponse); err != nil { 49 | return nil, err 50 | } 51 | 52 | return tokenListResponse.ServiceTokens, nil 53 | } 54 | 55 | func (s *serviceTokenService) Delete(ctx context.Context, delReq *DeleteServiceTokenRequest) error { 56 | req, err := s.client.newRequest(http.MethodDelete, serviceTokenAPIPath(delReq.Organization, delReq.ID), nil) 57 | if err != nil { 58 | return err 59 | } 60 | 61 | err = s.client.do(ctx, req, nil) 62 | return err 63 | } 64 | 65 | func (s *serviceTokenService) GetAccess(ctx context.Context, accessReq *GetServiceTokenAccessRequest) ([]*ServiceTokenAccess, error) { 66 | req, err := s.client.newRequest(http.MethodGet, serviceTokenAccessAPIPath(accessReq.Organization, accessReq.ID), nil) 67 | if err != nil { 68 | return nil, err 69 | } 70 | 71 | tokenAccess := serviceTokenAccessResponse{} 72 | if err := s.client.do(ctx, req, &tokenAccess); err != nil { 73 | return nil, err 74 | } 75 | return tokenAccess.ServiceTokenAccesses, nil 76 | } 77 | 78 | func (s *serviceTokenService) ListGrants(ctx context.Context, listReq *ListServiceTokenGrantsRequest) ([]*ServiceTokenGrant, error) { 79 | req, err := s.client.newRequest(http.MethodGet, serviceTokenGrantsAPIPath(listReq.Organization, listReq.ID), nil) 80 | if err != nil { 81 | return nil, err 82 | } 83 | 84 | tokenGrants := serviceTokenGrantsResponse{} 85 | if err := s.client.do(ctx, req, &tokenGrants); err != nil { 86 | return nil, err 87 | } 88 | return tokenGrants.ServiceTokenGrants, nil 89 | } 90 | 91 | func (s *serviceTokenService) AddAccess(ctx context.Context, addReq *AddServiceTokenAccessRequest) ([]*ServiceTokenAccess, error) { 92 | req, err := s.client.newRequest(http.MethodPost, serviceTokenAccessAPIPath(addReq.Organization, addReq.ID), addReq) 93 | if err != nil { 94 | return nil, err 95 | } 96 | 97 | tokenAccess := serviceTokenAccessResponse{} 98 | if err := s.client.do(ctx, req, &tokenAccess); err != nil { 99 | return nil, err 100 | } 101 | return tokenAccess.ServiceTokenAccesses, nil 102 | } 103 | 104 | func (s *serviceTokenService) DeleteAccess(ctx context.Context, delReq *DeleteServiceTokenAccessRequest) error { 105 | req, err := s.client.newRequest(http.MethodDelete, serviceTokenAccessAPIPath(delReq.Organization, delReq.ID), delReq) 106 | if err != nil { 107 | return err 108 | } 109 | 110 | err = s.client.do(ctx, req, nil) 111 | return err 112 | } 113 | 114 | type CreateServiceTokenRequest struct { 115 | Organization string `json:"-"` 116 | } 117 | 118 | type ListServiceTokenGrantsRequest struct { 119 | Organization string `json:"-"` 120 | ID string `json:"-"` 121 | } 122 | 123 | type DeleteServiceTokenRequest struct { 124 | Organization string `json:"-"` 125 | ID string `json:"-"` 126 | } 127 | 128 | type ListServiceTokensRequest struct { 129 | Organization string `json:"-"` 130 | } 131 | 132 | type GetServiceTokenAccessRequest struct { 133 | Organization string `json:"-"` 134 | ID string `json:"-"` 135 | } 136 | 137 | type AddServiceTokenAccessRequest struct { 138 | Organization string `json:"-"` 139 | ID string `json:"-"` 140 | Database string `json:"database"` 141 | Accesses []string `json:"access"` 142 | } 143 | 144 | type DeleteServiceTokenAccessRequest struct { 145 | Organization string `json:"-"` 146 | ID string `json:"-"` 147 | Database string `json:"database"` 148 | Accesses []string `json:"access"` 149 | } 150 | 151 | type ServiceToken struct { 152 | ID string `json:"id"` 153 | Type string `json:"type"` 154 | Token string `json:"token"` 155 | } 156 | 157 | type ServiceTokenGrant struct { 158 | ID string `json:"id"` 159 | ResourceName string `json:"resource_name"` 160 | ResourceType string `json:"resource_type"` 161 | ResourceID string `json:"resource_id"` 162 | Accesses []*ServiceTokenGrantAccess `json:"accesses"` 163 | } 164 | 165 | type ServiceTokenGrantAccess struct { 166 | Access string `json:"access"` 167 | Description string `json:"description"` 168 | } 169 | 170 | type serviceTokensResponse struct { 171 | ServiceTokens []*ServiceToken `json:"data"` 172 | } 173 | 174 | type ServiceTokenAccess struct { 175 | ID string `json:"id"` 176 | Access string `json:"access"` 177 | Type string `json:"type"` 178 | Resource ServiceTokenResource `json:"resource"` 179 | } 180 | 181 | type ServiceTokenResource struct { 182 | ID string `json:"id"` 183 | Name string `json:"name"` 184 | Type string `json:"type"` 185 | } 186 | 187 | type serviceTokenAccessResponse struct { 188 | ServiceTokenAccesses []*ServiceTokenAccess `json:"data"` 189 | } 190 | 191 | type serviceTokenGrantsResponse struct { 192 | ServiceTokenGrants []*ServiceTokenGrant `json:"data"` 193 | } 194 | 195 | func serviceTokenAccessAPIPath(org, id string) string { 196 | return fmt.Sprintf("%s/%s/access", serviceTokensAPIPath(org), id) 197 | } 198 | 199 | func serviceTokenGrantsAPIPath(org, id string) string { 200 | return fmt.Sprintf("%s/%s/grants", serviceTokensAPIPath(org), id) 201 | } 202 | 203 | func serviceTokensAPIPath(org string) string { 204 | return fmt.Sprintf("v1/organizations/%s/service-tokens", org) 205 | } 206 | 207 | func serviceTokenAPIPath(org, id string) string { 208 | return fmt.Sprintf("%s/%s", serviceTokensAPIPath(org), id) 209 | } 210 | -------------------------------------------------------------------------------- /planetscale/service_tokens_test.go: -------------------------------------------------------------------------------- 1 | package planetscale 2 | 3 | import ( 4 | "context" 5 | "io" 6 | "net/http" 7 | "net/http/httptest" 8 | "testing" 9 | 10 | qt "github.com/frankban/quicktest" 11 | ) 12 | 13 | func TestServiceTokens_Create(t *testing.T) { 14 | c := qt.New(t) 15 | 16 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 17 | w.WriteHeader(200) 18 | out := `{"id":"test-id","type":"ServiceToken","token":"d2980bbd91a4ab878601ef0573a7af7b1b15e705"}` 19 | _, err := w.Write([]byte(out)) 20 | c.Assert(err, qt.IsNil) 21 | })) 22 | 23 | client, err := NewClient(WithBaseURL(ts.URL)) 24 | c.Assert(err, qt.IsNil) 25 | 26 | ctx := context.Background() 27 | 28 | snapshot, err := client.ServiceTokens.Create(ctx, &CreateServiceTokenRequest{ 29 | Organization: testOrg, 30 | }) 31 | want := &ServiceToken{ 32 | ID: "test-id", 33 | Type: "ServiceToken", 34 | Token: "d2980bbd91a4ab878601ef0573a7af7b1b15e705", 35 | } 36 | 37 | c.Assert(err, qt.IsNil) 38 | c.Assert(snapshot, qt.DeepEquals, want) 39 | } 40 | 41 | func TestServiceTokens_ListGrants(t *testing.T) { 42 | c := qt.New(t) 43 | 44 | out := `{"type":"list","current_page":1,"next_page":null,"next_page_url":null,"prev_page":null,"prev_page_url":null,"data":[{"id":"qbphfi83nxti","type":"ServiceTokenGrant","resource_name":"planetscale","resource_type":"Database","resource_id":"qbphfi83nxti","accesses":[{"access": "read_branch", "description": "Read database branch"}]}]}` 45 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 46 | w.WriteHeader(200) 47 | _, err := w.Write([]byte(out)) 48 | c.Assert(err, qt.IsNil) 49 | })) 50 | 51 | client, err := NewClient(WithBaseURL(ts.URL)) 52 | c.Assert(err, qt.IsNil) 53 | 54 | ctx := context.Background() 55 | 56 | grants, err := client.ServiceTokens.ListGrants(ctx, &ListServiceTokenGrantsRequest{ 57 | Organization: testOrg, 58 | ID: "1234", 59 | }) 60 | 61 | want := []*ServiceTokenGrant{ 62 | { 63 | ID: "qbphfi83nxti", 64 | ResourceName: "planetscale", 65 | ResourceType: "Database", 66 | ResourceID: "qbphfi83nxti", 67 | Accesses: []*ServiceTokenGrantAccess{{Access: "read_branch", Description: "Read database branch"}}, 68 | }, 69 | } 70 | 71 | c.Assert(err, qt.IsNil) 72 | c.Assert(grants, qt.DeepEquals, want) 73 | } 74 | 75 | func TestServiceTokens_List(t *testing.T) { 76 | c := qt.New(t) 77 | 78 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 79 | w.WriteHeader(200) 80 | out := `{"type":"list","next_page":null,"prev_page":null,"data":[{"id":"txhc257pxjuc","type":"ServiceToken","token":null}]}` 81 | _, err := w.Write([]byte(out)) 82 | c.Assert(err, qt.IsNil) 83 | })) 84 | 85 | client, err := NewClient(WithBaseURL(ts.URL)) 86 | c.Assert(err, qt.IsNil) 87 | 88 | ctx := context.Background() 89 | 90 | snapshot, err := client.ServiceTokens.List(ctx, &ListServiceTokensRequest{ 91 | Organization: testOrg, 92 | }) 93 | want := []*ServiceToken{ 94 | { 95 | ID: "txhc257pxjuc", 96 | Type: "ServiceToken", 97 | }, 98 | } 99 | 100 | c.Assert(err, qt.IsNil) 101 | c.Assert(snapshot, qt.DeepEquals, want) 102 | } 103 | 104 | func TestServiceTokens_Delete(t *testing.T) { 105 | c := qt.New(t) 106 | 107 | wantURL := "/v1/organizations/my-org/service-tokens/1234" 108 | 109 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 110 | w.WriteHeader(200) 111 | c.Assert(r.URL.String(), qt.DeepEquals, wantURL) 112 | })) 113 | 114 | client, err := NewClient(WithBaseURL(ts.URL)) 115 | c.Assert(err, qt.IsNil) 116 | 117 | ctx := context.Background() 118 | 119 | err = client.ServiceTokens.Delete(ctx, &DeleteServiceTokenRequest{ 120 | Organization: testOrg, 121 | ID: "1234", 122 | }) 123 | 124 | c.Assert(err, qt.IsNil) 125 | } 126 | 127 | func TestServiceTokens_GetAccess(t *testing.T) { 128 | c := qt.New(t) 129 | 130 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 131 | w.WriteHeader(200) 132 | out := `{"type":"list","next_page":null,"prev_page":null,"data":[{"id":"hjqui654yu71","type":"DatabaseAccess","resource":{"id":"1lbjwnp48b6r","type":"Database","name":"hidden-river-4209","created_at":"2021-01-14T10:19:23.000Z","updated_at":"2021-01-14T10:19:23.000Z"},"access":"read_comment"}]}` 133 | _, err := w.Write([]byte(out)) 134 | c.Assert(err, qt.IsNil) 135 | })) 136 | 137 | client, err := NewClient(WithBaseURL(ts.URL)) 138 | c.Assert(err, qt.IsNil) 139 | 140 | ctx := context.Background() 141 | 142 | snapshot, err := client.ServiceTokens.GetAccess(ctx, &GetServiceTokenAccessRequest{ 143 | Organization: testOrg, 144 | ID: "1234", 145 | }) 146 | want := []*ServiceTokenAccess{ 147 | { 148 | ID: "hjqui654yu71", 149 | Access: "read_comment", 150 | Type: "DatabaseAccess", 151 | Resource: ServiceTokenResource{ 152 | ID: "1lbjwnp48b6r", 153 | Name: "hidden-river-4209", 154 | Type: "Database", 155 | }, 156 | }, 157 | } 158 | 159 | c.Assert(err, qt.IsNil) 160 | c.Assert(snapshot, qt.DeepEquals, want) 161 | } 162 | 163 | func TestServiceTokens_AddAccess(t *testing.T) { 164 | c := qt.New(t) 165 | 166 | wantBody := []byte("{\"database\":\"hidden-river-4209\",\"access\":[\"read_comment\"]}\n") 167 | 168 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 169 | w.WriteHeader(200) 170 | data, err := io.ReadAll(r.Body) 171 | c.Assert(err, qt.IsNil) 172 | c.Assert(data, qt.DeepEquals, wantBody) 173 | 174 | out := `{"type":"list","next_page":null,"prev_page":null,"data":[{"id":"hjqui654yu71","type":"DatabaseAccess","resource":{"id":"1lbjwnp48b6r","type":"Database","name":"hidden-river-4209","created_at":"2021-01-14T10:19:23.000Z","updated_at":"2021-01-14T10:19:23.000Z"},"access":"read_comment"}]}` 175 | _, err = w.Write([]byte(out)) 176 | c.Assert(err, qt.IsNil) 177 | })) 178 | 179 | client, err := NewClient(WithBaseURL(ts.URL)) 180 | c.Assert(err, qt.IsNil) 181 | 182 | ctx := context.Background() 183 | 184 | snapshot, err := client.ServiceTokens.AddAccess(ctx, &AddServiceTokenAccessRequest{ 185 | Organization: testOrg, 186 | ID: "1234", 187 | Database: "hidden-river-4209", 188 | Accesses: []string{"read_comment"}, 189 | }) 190 | want := []*ServiceTokenAccess{ 191 | { 192 | ID: "hjqui654yu71", 193 | Access: "read_comment", 194 | Type: "DatabaseAccess", 195 | Resource: ServiceTokenResource{ 196 | ID: "1lbjwnp48b6r", 197 | Name: "hidden-river-4209", 198 | Type: "Database", 199 | }, 200 | }, 201 | } 202 | 203 | c.Assert(err, qt.IsNil) 204 | c.Assert(snapshot, qt.DeepEquals, want) 205 | } 206 | 207 | func TestServiceTokens_DeleteAccess(t *testing.T) { 208 | c := qt.New(t) 209 | 210 | wantBody := []byte("{\"database\":\"hidden-river-4209\",\"access\":[\"read_comment\"]}\n") 211 | 212 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 213 | w.WriteHeader(200) 214 | data, err := io.ReadAll(r.Body) 215 | c.Assert(err, qt.IsNil) 216 | c.Assert(data, qt.DeepEquals, wantBody) 217 | })) 218 | 219 | client, err := NewClient(WithBaseURL(ts.URL)) 220 | c.Assert(err, qt.IsNil) 221 | 222 | ctx := context.Background() 223 | 224 | err = client.ServiceTokens.DeleteAccess(ctx, &DeleteServiceTokenAccessRequest{ 225 | Organization: testOrg, 226 | ID: "1234", 227 | Database: "hidden-river-4209", 228 | Accesses: []string{"read_comment"}, 229 | }) 230 | 231 | c.Assert(err, qt.IsNil) 232 | } 233 | -------------------------------------------------------------------------------- /planetscale/workflows.go: -------------------------------------------------------------------------------- 1 | package planetscale 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | "time" 8 | ) 9 | 10 | type Workflow struct { 11 | ID string `json:"id"` 12 | Name string `json:"name"` 13 | Number uint64 `json:"number"` 14 | State string `json:"state"` 15 | CreatedAt time.Time `json:"created_at"` 16 | UpdatedAt time.Time `json:"updated_at"` 17 | StartedAt *time.Time `json:"started_at"` 18 | CompletedAt *time.Time `json:"completed_at"` 19 | CancelledAt *time.Time `json:"cancelled_at"` 20 | ReversedAt *time.Time `json:"reversed_at"` 21 | RetriedAt *time.Time `json:"retried_at"` 22 | DataCopyCompletedAt *time.Time `json:"data_copy_completed_at"` 23 | CutoverAt *time.Time `json:"cutover_at"` 24 | ReplicasSwitched bool `json:"replicas_switched"` 25 | PrimariesSwitched bool `json:"primaries_switched"` 26 | SwitchReplicasAt *time.Time `json:"switch_replicas_at"` 27 | SwitchPrimariesAt *time.Time `json:"switch_primaries_at"` 28 | VerifyDataAt *time.Time `json:"verify_data_at"` 29 | 30 | Branch DatabaseBranch `json:"branch"` 31 | SourceKeyspace Keyspace `json:"source_keyspace"` 32 | TargetKeyspace Keyspace `json:"target_keyspace"` 33 | 34 | Actor Actor `json:"actor"` 35 | VerifyDataBy *Actor `json:"verify_data_by"` 36 | ReversedBy *Actor `json:"reversed_by"` 37 | SwitchReplicasBy *Actor `json:"switch_replicas_by"` 38 | SwitchPrimariesBy *Actor `json:"switch_primaries_by"` 39 | CancelledBy *Actor `json:"cancelled_by"` 40 | CompletedBy *Actor `json:"completed_by"` 41 | RetriedBy *Actor `json:"retried_by"` 42 | CutoverBy *Actor `json:"cutover_by"` 43 | ReversedCutoverBy *Actor `json:"reversed_cutover_by"` 44 | 45 | Streams []*WorkflowStream `json:"streams"` 46 | Tables []*WorkflowTable `json:"tables"` 47 | VDiff *WorkflowVDiff `json:"vdiff"` 48 | } 49 | 50 | type WorkflowStream struct { 51 | PublicID string `json:"id"` 52 | State string `json:"state"` 53 | CreatedAt time.Time `json:"created_at"` 54 | UpdatedAt time.Time `json:"updated_at"` 55 | TargetShard string `json:"target_shard"` 56 | SourceShard string `json:"source_shard"` 57 | Position string `json:"position"` 58 | StopPosition string `json:"stop_position"` 59 | RowsCopied int64 `json:"rows_copied"` 60 | ComponentThrottled *string `json:"component_throttled"` 61 | ComponentThrottledAt *time.Time `json:"component_throttled_at"` 62 | PrimaryServing bool `json:"primary_serving"` 63 | Info string `json:"info"` 64 | Logs []WorkflowStreamLog `json:"logs"` 65 | } 66 | 67 | type WorkflowStreamLog struct { 68 | PublicID string `json:"id"` 69 | State string `json:"state"` 70 | CreatedAt time.Time `json:"created_at"` 71 | UpdatedAt time.Time `json:"updated_at"` 72 | Message string `json:"message"` 73 | LogType string `json:"log_type"` 74 | } 75 | 76 | type WorkflowTable struct { 77 | PublicID string `json:"id"` 78 | Name string `json:"name"` 79 | CreatedAt time.Time `json:"created_at"` 80 | UpdatedAt time.Time `json:"updated_at"` 81 | RowsCopied uint64 `json:"rows_copied"` 82 | RowsTotal uint64 `json:"rows_total"` 83 | RowsPercentage uint `json:"rows_percentage"` 84 | } 85 | 86 | type WorkflowVDiff struct { 87 | PublicID string `json:"id"` 88 | State string `json:"state"` 89 | CreatedAt time.Time `json:"created_at"` 90 | UpdatedAt time.Time `json:"updated_at"` 91 | StartedAt *time.Time `json:"started_at"` 92 | CompletedAt *time.Time `json:"completed_at"` 93 | HasMismatch bool `json:"has_mismatch"` 94 | ProgressPercentage uint `json:"progress_percentage"` 95 | EtaSeconds uint64 `json:"eta_seconds"` 96 | TableReports []WorkflowVDiffTableReport `json:"table_reports"` 97 | } 98 | 99 | type WorkflowVDiffTableReport struct { 100 | PublicID string `json:"id"` 101 | TableName string `json:"table_name"` 102 | Shard string `json:"shard"` 103 | MismatchedRowsCount int64 `json:"mismatched_rows_count"` 104 | ExtraSourceRowsCount int64 `json:"extra_source_rows_count"` 105 | ExtraTargetRowsCount int64 `json:"extra_target_rows_count"` 106 | ExtraSourceRows []interface{} `json:"extra_source_rows"` 107 | ExtraTargetRows []interface{} `json:"extra_target_rows"` 108 | MismatchedRows []interface{} `json:"mismatched_rows"` 109 | SampleExtraSourceRowsQuery string `json:"sample_extra_source_rows_query"` 110 | SampleExtraTargetRowsQuery string `json:"sample_extra_target_rows_query"` 111 | SampleMismatchedRowsQuery string `json:"sample_mismatched_rows_query"` 112 | CreatedAt time.Time `json:"created_at"` 113 | UpdatedAt time.Time `json:"updated_at"` 114 | } 115 | 116 | type ListWorkflowsRequest struct { 117 | Organization string `json:"-"` 118 | Database string `json:"-"` 119 | } 120 | 121 | type GetWorkflowRequest struct { 122 | Organization string `json:"-"` 123 | Database string `json:"-"` 124 | WorkflowNumber uint64 `json:"-"` 125 | } 126 | 127 | type VerifyDataWorkflowRequest struct { 128 | Organization string `json:"-"` 129 | Database string `json:"-"` 130 | WorkflowNumber uint64 `json:"-"` 131 | } 132 | 133 | type SwitchReplicasWorkflowRequest struct { 134 | Organization string `json:"-"` 135 | Database string `json:"-"` 136 | WorkflowNumber uint64 `json:"-"` 137 | } 138 | 139 | type SwitchPrimariesWorkflowRequest struct { 140 | Organization string `json:"-"` 141 | Database string `json:"-"` 142 | WorkflowNumber uint64 `json:"-"` 143 | } 144 | 145 | type ReverseTrafficWorkflowRequest struct { 146 | Organization string `json:"-"` 147 | Database string `json:"-"` 148 | WorkflowNumber uint64 `json:"-"` 149 | } 150 | 151 | type CutoverWorkflowRequest struct { 152 | Organization string `json:"-"` 153 | Database string `json:"-"` 154 | WorkflowNumber uint64 `json:"-"` 155 | } 156 | 157 | type ReverseCutoverWorkflowRequest struct { 158 | Organization string `json:"-"` 159 | Database string `json:"-"` 160 | WorkflowNumber uint64 `json:"-"` 161 | } 162 | 163 | type CompleteWorkflowRequest struct { 164 | Organization string `json:"-"` 165 | Database string `json:"-"` 166 | WorkflowNumber uint64 `json:"-"` 167 | } 168 | 169 | type RetryWorkflowRequest struct { 170 | Organization string `json:"-"` 171 | Database string `json:"-"` 172 | WorkflowNumber uint64 `json:"-"` 173 | } 174 | 175 | type CancelWorkflowRequest struct { 176 | Organization string `json:"-"` 177 | Database string `json:"-"` 178 | WorkflowNumber uint64 `json:"-"` 179 | } 180 | 181 | type CreateWorkflowRequest struct { 182 | Organization string `json:"-"` 183 | Database string `json:"-"` 184 | Branch string `json:"branch_name"` 185 | Name string `json:"name"` 186 | SourceKeyspace string `json:"source_keyspace"` 187 | TargetKeyspace string `json:"target_keyspace"` 188 | Tables []string `json:"tables"` 189 | GlobalKeyspace *string `json:"global_keyspace"` 190 | DeferSecondaryKeys *bool `json:"defer_secondary_keys"` 191 | OnDDL *string `json:"on_ddl"` 192 | } 193 | 194 | // WorkflowsService is an interface for interacting with the workflow endpoints of the PlanetScale API 195 | type WorkflowsService interface { 196 | List(context.Context, *ListWorkflowsRequest) ([]*Workflow, error) 197 | Get(context.Context, *GetWorkflowRequest) (*Workflow, error) 198 | Create(context.Context, *CreateWorkflowRequest) (*Workflow, error) 199 | VerifyData(context.Context, *VerifyDataWorkflowRequest) (*Workflow, error) 200 | SwitchReplicas(context.Context, *SwitchReplicasWorkflowRequest) (*Workflow, error) 201 | SwitchPrimaries(context.Context, *SwitchPrimariesWorkflowRequest) (*Workflow, error) 202 | ReverseTraffic(context.Context, *ReverseTrafficWorkflowRequest) (*Workflow, error) 203 | Cutover(context.Context, *CutoverWorkflowRequest) (*Workflow, error) 204 | ReverseCutover(context.Context, *ReverseCutoverWorkflowRequest) (*Workflow, error) 205 | Complete(context.Context, *CompleteWorkflowRequest) (*Workflow, error) 206 | Retry(context.Context, *RetryWorkflowRequest) (*Workflow, error) 207 | Cancel(context.Context, *CancelWorkflowRequest) (*Workflow, error) 208 | } 209 | 210 | type workflowsService struct { 211 | client *Client 212 | } 213 | 214 | var _ WorkflowsService = &workflowsService{} 215 | 216 | func NewWorkflowsService(client *Client) *workflowsService { 217 | return &workflowsService{client} 218 | } 219 | 220 | type workflowsResponse struct { 221 | Workflows []*Workflow `json:"data"` 222 | } 223 | 224 | func (ws *workflowsService) List(ctx context.Context, listReq *ListWorkflowsRequest) ([]*Workflow, error) { 225 | req, err := ws.client.newRequest(http.MethodGet, workflowsAPIPath(listReq.Organization, listReq.Database), nil) 226 | if err != nil { 227 | return nil, fmt.Errorf("error creating http request: %w", err) 228 | } 229 | 230 | workflows := &workflowsResponse{} 231 | 232 | if err := ws.client.do(ctx, req, workflows); err != nil { 233 | return nil, err 234 | } 235 | 236 | return workflows.Workflows, nil 237 | } 238 | 239 | func (ws *workflowsService) Get(ctx context.Context, getReq *GetWorkflowRequest) (*Workflow, error) { 240 | req, err := ws.client.newRequest(http.MethodGet, workflowAPIPath(getReq.Organization, getReq.Database, getReq.WorkflowNumber), nil) 241 | if err != nil { 242 | return nil, fmt.Errorf("error creating http request: %w", err) 243 | } 244 | 245 | workflow := &Workflow{} 246 | 247 | if err := ws.client.do(ctx, req, workflow); err != nil { 248 | return nil, err 249 | } 250 | 251 | return workflow, nil 252 | } 253 | 254 | func (ws *workflowsService) Create(ctx context.Context, createReq *CreateWorkflowRequest) (*Workflow, error) { 255 | req, err := ws.client.newRequest(http.MethodPost, workflowsAPIPath(createReq.Organization, createReq.Database), createReq) 256 | 257 | if err != nil { 258 | return nil, fmt.Errorf("error creating http request: %w", err) 259 | } 260 | 261 | workflow := &Workflow{} 262 | 263 | if err := ws.client.do(ctx, req, workflow); err != nil { 264 | return nil, err 265 | } 266 | 267 | return workflow, nil 268 | } 269 | 270 | func (ws *workflowsService) VerifyData(ctx context.Context, verifyDataReq *VerifyDataWorkflowRequest) (*Workflow, error) { 271 | path := fmt.Sprintf("%s/verify-data", workflowAPIPath(verifyDataReq.Organization, verifyDataReq.Database, verifyDataReq.WorkflowNumber)) 272 | req, err := ws.client.newRequest(http.MethodPatch, path, nil) 273 | 274 | if err != nil { 275 | return nil, fmt.Errorf("error creating http request: %w", err) 276 | } 277 | 278 | workflow := &Workflow{} 279 | 280 | if err := ws.client.do(ctx, req, workflow); err != nil { 281 | return nil, err 282 | } 283 | 284 | return workflow, nil 285 | } 286 | 287 | func (ws *workflowsService) SwitchReplicas(ctx context.Context, switchReplicasReq *SwitchReplicasWorkflowRequest) (*Workflow, error) { 288 | path := fmt.Sprintf("%s/switch-replicas", workflowAPIPath(switchReplicasReq.Organization, switchReplicasReq.Database, switchReplicasReq.WorkflowNumber)) 289 | req, err := ws.client.newRequest(http.MethodPatch, path, nil) 290 | 291 | if err != nil { 292 | return nil, fmt.Errorf("error creating http request: %w", err) 293 | } 294 | 295 | workflow := &Workflow{} 296 | 297 | if err := ws.client.do(ctx, req, workflow); err != nil { 298 | return nil, err 299 | } 300 | 301 | return workflow, nil 302 | } 303 | 304 | func (ws *workflowsService) SwitchPrimaries(ctx context.Context, switchPrimariesReq *SwitchPrimariesWorkflowRequest) (*Workflow, error) { 305 | path := fmt.Sprintf("%s/switch-primaries", workflowAPIPath(switchPrimariesReq.Organization, switchPrimariesReq.Database, switchPrimariesReq.WorkflowNumber)) 306 | req, err := ws.client.newRequest(http.MethodPatch, path, nil) 307 | 308 | if err != nil { 309 | return nil, fmt.Errorf("error creating http request: %w", err) 310 | } 311 | 312 | workflow := &Workflow{} 313 | 314 | if err := ws.client.do(ctx, req, workflow); err != nil { 315 | return nil, err 316 | } 317 | 318 | return workflow, nil 319 | } 320 | 321 | func (ws *workflowsService) ReverseTraffic(ctx context.Context, reverseTrafficReq *ReverseTrafficWorkflowRequest) (*Workflow, error) { 322 | path := fmt.Sprintf("%s/reverse-traffic", workflowAPIPath(reverseTrafficReq.Organization, reverseTrafficReq.Database, reverseTrafficReq.WorkflowNumber)) 323 | req, err := ws.client.newRequest(http.MethodPatch, path, nil) 324 | 325 | if err != nil { 326 | return nil, fmt.Errorf("error creating http request: %w", err) 327 | } 328 | 329 | workflow := &Workflow{} 330 | 331 | if err := ws.client.do(ctx, req, workflow); err != nil { 332 | return nil, err 333 | } 334 | 335 | return workflow, nil 336 | } 337 | 338 | func (ws *workflowsService) Cutover(ctx context.Context, cutoverReq *CutoverWorkflowRequest) (*Workflow, error) { 339 | path := fmt.Sprintf("%s/cutover", workflowAPIPath(cutoverReq.Organization, cutoverReq.Database, cutoverReq.WorkflowNumber)) 340 | req, err := ws.client.newRequest(http.MethodPatch, path, nil) 341 | 342 | if err != nil { 343 | return nil, fmt.Errorf("error creating http request: %w", err) 344 | } 345 | 346 | workflow := &Workflow{} 347 | 348 | if err := ws.client.do(ctx, req, workflow); err != nil { 349 | return nil, err 350 | } 351 | 352 | return workflow, nil 353 | } 354 | 355 | func (ws *workflowsService) ReverseCutover(ctx context.Context, reverseCutoverReq *ReverseCutoverWorkflowRequest) (*Workflow, error) { 356 | path := fmt.Sprintf("%s/reverse-cutover", workflowAPIPath(reverseCutoverReq.Organization, reverseCutoverReq.Database, reverseCutoverReq.WorkflowNumber)) 357 | req, err := ws.client.newRequest(http.MethodPatch, path, nil) 358 | 359 | if err != nil { 360 | return nil, fmt.Errorf("error creating http request: %w", err) 361 | } 362 | 363 | workflow := &Workflow{} 364 | 365 | if err := ws.client.do(ctx, req, workflow); err != nil { 366 | return nil, err 367 | } 368 | 369 | return workflow, nil 370 | } 371 | 372 | func (ws *workflowsService) Complete(ctx context.Context, completeReq *CompleteWorkflowRequest) (*Workflow, error) { 373 | path := fmt.Sprintf("%s/complete", workflowAPIPath(completeReq.Organization, completeReq.Database, completeReq.WorkflowNumber)) 374 | req, err := ws.client.newRequest(http.MethodPatch, path, nil) 375 | 376 | if err != nil { 377 | return nil, fmt.Errorf("error creating http request: %w", err) 378 | } 379 | 380 | workflow := &Workflow{} 381 | 382 | if err := ws.client.do(ctx, req, workflow); err != nil { 383 | return nil, err 384 | } 385 | 386 | return workflow, nil 387 | } 388 | 389 | func (ws *workflowsService) Retry(ctx context.Context, retryReq *RetryWorkflowRequest) (*Workflow, error) { 390 | path := fmt.Sprintf("%s/retry", workflowAPIPath(retryReq.Organization, retryReq.Database, retryReq.WorkflowNumber)) 391 | req, err := ws.client.newRequest(http.MethodPatch, path, nil) 392 | 393 | if err != nil { 394 | return nil, fmt.Errorf("error creating http request: %w", err) 395 | } 396 | 397 | workflow := &Workflow{} 398 | 399 | if err := ws.client.do(ctx, req, workflow); err != nil { 400 | return nil, err 401 | } 402 | 403 | return workflow, nil 404 | } 405 | 406 | func (ws *workflowsService) Cancel(ctx context.Context, cancelReq *CancelWorkflowRequest) (*Workflow, error) { 407 | path := workflowAPIPath(cancelReq.Organization, cancelReq.Database, cancelReq.WorkflowNumber) 408 | req, err := ws.client.newRequest(http.MethodDelete, path, nil) 409 | 410 | if err != nil { 411 | return nil, fmt.Errorf("error creating http request: %w", err) 412 | } 413 | 414 | workflow := &Workflow{} 415 | 416 | if err := ws.client.do(ctx, req, workflow); err != nil { 417 | return nil, err 418 | } 419 | 420 | return workflow, nil 421 | } 422 | func workflowsAPIPath(org, db string) string { 423 | return fmt.Sprintf("%s/%s/workflows", databasesAPIPath(org), db) 424 | } 425 | 426 | func workflowAPIPath(org, db string, number uint64) string { 427 | return fmt.Sprintf("%s/%d", workflowsAPIPath(org, db), number) 428 | } 429 | --------------------------------------------------------------------------------