├── .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 [](https://pkg.go.dev/github.com/planetscale/planetscale-go/planetscale) [](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 |
--------------------------------------------------------------------------------