From c074f54149f2c4890c4a41e889e4bf4c558c6bd6 Mon Sep 17 00:00:00 2001 From: Matt Anderson <42154938+matoszz@users.noreply.github.com> Date: Sun, 25 Aug 2024 23:10:28 -0500 Subject: [PATCH] init go-turso (#1) --- .buildkite/pipeline.yaml | 69 +++++ .github/CODEOWNERS | 1 + .github/ISSUE_TEMPLATE/bug_report.md | 16 ++ .github/ISSUE_TEMPLATE/feature_request.md | 14 + .github/labeler.yml | 15 ++ .github/release.yaml | 21 ++ .gitignore | 43 +++ .golangci.yaml | 44 ++++ .pre-commit-config.yaml | 16 ++ .typos.toml | 20 ++ .yamlfmt | 2 + LICENSE | 2 +- README.md | 132 +++++++++- Taskfile.yaml | 33 +++ client.go | 84 ++++++ config.go | 11 + database.go | 234 +++++++++++++++++ database_test.go | 102 +++++++ database_tokens.go | 139 ++++++++++ database_tokens_test.go | 130 +++++++++ doc.go | 2 + errors.go | 81 ++++++ go.mod | 14 + go.sum | 12 + group.go | 307 ++++++++++++++++++++++ group_test.go | 219 +++++++++++++++ organization.go | 60 +++++ organization_test.go | 17 ++ renovate.json | 11 + sonar-project.properties | 16 ++ test_tools.go | 215 +++++++++++++++ 31 files changed, 2080 insertions(+), 2 deletions(-) create mode 100644 .buildkite/pipeline.yaml create mode 100644 .github/CODEOWNERS create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/feature_request.md create mode 100644 .github/labeler.yml create mode 100644 .github/release.yaml create mode 100644 .gitignore create mode 100644 .golangci.yaml create mode 100644 .pre-commit-config.yaml create mode 100644 .typos.toml create mode 100644 .yamlfmt create mode 100644 Taskfile.yaml create mode 100644 client.go create mode 100644 config.go create mode 100644 database.go create mode 100644 database_test.go create mode 100644 database_tokens.go create mode 100644 database_tokens_test.go create mode 100644 doc.go create mode 100644 errors.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 group.go create mode 100644 group_test.go create mode 100644 organization.go create mode 100644 organization_test.go create mode 100644 renovate.json create mode 100644 sonar-project.properties create mode 100644 test_tools.go diff --git a/.buildkite/pipeline.yaml b/.buildkite/pipeline.yaml new file mode 100644 index 0000000..361d56a --- /dev/null +++ b/.buildkite/pipeline.yaml @@ -0,0 +1,69 @@ +env: + APP_NAME: ${BUILDKITE_PIPELINE_SLUG} + SONAR_HOST: "https://sonarcloud.io" + +steps: + - group: ":test_tube: Tests" + key: "tests" + steps: + - label: ":golangci-lint: lint :lint-roller:" + key: "lint" + plugins: + - docker#v5.11.0: + image: "registry.hub.docker.com/golangci/golangci-lint:latest-alpine" + command: ["golangci-lint", "run", "-v"] + always-pull: true + environment: + - "GOTOOLCHAIN=auto" + - label: ":golang: go test" + key: "go_test" + plugins: + - docker#v5.11.0: + image: golang:1.23.0 + command: ["go", "test", "-coverprofile=coverage.out", "./..."] + artifact_paths: ["coverage.out"] + - group: ":closed_lock_with_key: Security Checks" + depends_on: "tests" + key: "security" + steps: + - label: ":closed_lock_with_key: gosec" + key: "gosec" + plugins: + - docker#v5.11.0: + image: "registry.hub.docker.com/securego/gosec:2.20.0" + command: ["-no-fail", "-exclude-generated", "-fmt sonarqube", "-out", "results.txt", "./..."] + environment: + - "GOTOOLCHAIN=auto" + artifact_paths: ["results.txt"] + - label: ":github: upload PR reports" + key: "scan-upload-pr" + if: build.pull_request.id != null + depends_on: ["gosec", "go_test"] + plugins: + - artifacts#v1.9.4: + download: "results.txt" + - artifacts#v1.9.4: + download: "coverage.out" + step: "go_test" + - docker#v5.11.0: + image: "sonarsource/sonar-scanner-cli:5" + environment: + - "SONAR_TOKEN" + - "SONAR_HOST_URL=$SONAR_HOST" + - "SONAR_SCANNER_OPTS=-Dsonar.pullrequest.branch=$BUILDKITE_BRANCH -Dsonar.pullrequest.base=$BUILDKITE_PULL_REQUEST_BASE_BRANCH -Dsonar.pullrequest.key=$BUILDKITE_PULL_REQUEST" + - label: ":github: upload reports" + key: "scan-upload" + if: build.branch == "main" + depends_on: ["gosec", "go_test"] + plugins: + - artifacts#v1.9.4: + download: results.txt + - artifacts#v1.9.4: + download: coverage.out + step: "go_test" + - docker#v5.11.0: + image: "sonarsource/sonar-scanner-cli:5" + environment: + - "SONAR_TOKEN" + - "SONAR_HOST_URL=$SONAR_HOST" + diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..f906a12 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @theopenlane/blacksmiths \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..263ebe5 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,16 @@ +--- +name: Bug report +about: Create a report to help us improve +title: "[Bug]" +labels: bug +assignees: '' + +--- + +**Describe the bug or issue you're encountering** + + +**What are the relevant steps to reproduce, including the version(s) of the relevant software?** + + +**What is the expected behavior?** diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..897f8f2 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,14 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: "[Feature Request]" +labels: enhancement +assignees: matoszz + +--- + +**Describe how the feature might make your life easier or solve a problem** + +**Describe the solution you'd like to see with any relevant context** + +**Describe any alternatives you've considered or if there are short-tern vs. long-term options** diff --git a/.github/labeler.yml b/.github/labeler.yml new file mode 100644 index 0000000..2aac7e4 --- /dev/null +++ b/.github/labeler.yml @@ -0,0 +1,15 @@ +# Add 'bug' label to any PR where the head branch name starts with `bug` or has a `bug` section in the name +bug: + - head-branch: ["^bug", "bug"] +# Add 'enhancement' label to any PR where the head branch name starts with `enhancement` or has a `enhancement` section in the name +enhancement: + - head-branch: ["^enhancement", "enhancement", "^feature", "feature", "^enhance", "enhance", "^feat", "feat"] +# Add 'breaking-change' label to any PR where the head branch name starts with `breaking-change` or has a `breaking-change` section in the name +breaking-change: + - head-branch: ["^breaking-change", "breaking-change"] +ci: + - changed-files: + - any-glob-to-any-file: .github/** + - any-glob-to-any-file: .buildkite/** + + diff --git a/.github/release.yaml b/.github/release.yaml new file mode 100644 index 0000000..cd26d54 --- /dev/null +++ b/.github/release.yaml @@ -0,0 +1,21 @@ +exclude: + labels: + - ignore-for-release + authors: + - octocat + - github-actions +categories: + - title: Breaking Changes 🛠 + labels: + - Semver-Major + - breaking-change + - title: New Features 🎉 + labels: + - Semver-Minor + - enhancement + - title: 👒 Dependencies + labels: + - dependencies + - title: Other Changes + labels: + - "*" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6978059 --- /dev/null +++ b/.gitignore @@ -0,0 +1,43 @@ +*.exe +*.exe~ +*.dll +*.so +*.dylib + +*.test + +*.out + +go.work + +*.db +server.crt +server.key +private_key.pem +public_key.pem + +*.7z +*.dmg +*.gz +*.iso +*.jar +*.rar +*.tar + +*.log + +.vscode + +.DS_Store* +.AppleDouble +.LSOverride +ehthumbs.db +Icon? +Thumbs.db + +*.mime +*.mim + +*.env +*.env-dev +*.config.yaml \ No newline at end of file diff --git a/.golangci.yaml b/.golangci.yaml new file mode 100644 index 0000000..f8d612c --- /dev/null +++ b/.golangci.yaml @@ -0,0 +1,44 @@ +run: + timeout: 10m + allow-serial-runners: true + concurrency: 0 +linters-settings: + goimports: + local-prefixes: github.com/theopenlane/go-turso + gofumpt: + extra-rules: true + gosec: + exclude-generated: true + revive: + ignore-generated-header: true +linters: + enable: + - bodyclose + - errcheck + - gocritic + - gocyclo + - err113 + - gofmt + - goimports + - mnd + - gosimple + - govet + - gosec + - ineffassign + - misspell + - noctx + - revive + - staticcheck + - stylecheck + - typecheck + - unused + - whitespace + - wsl +issues: + fix: true + exclude-use-default: true + exclude-dirs: + - vanilla/* + - .buildkite/* + - .github/* + - templates/* diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..c725ca1 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,16 @@ +default_stages: [pre-commit] +fail_fast: true +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.6.0 + hooks: + - id: trailing-whitespace + - id: detect-private-key + - repo: https://github.com/google/yamlfmt + rev: v0.13.0 + hooks: + - id: yamlfmt + - repo: https://github.com/crate-ci/typos + rev: v1.24.1 + hooks: + - id: typos diff --git a/.typos.toml b/.typos.toml new file mode 100644 index 0000000..52a69c0 --- /dev/null +++ b/.typos.toml @@ -0,0 +1,20 @@ +[files] +extend-exclude = ["db","internal/ent/generated/**","go.mod","go.sum","pkg/testutils/","pkg/passwd/","pkg/tokens/testdata/","pkg/tokens/expires_test.go","internal/graphapi/tools_test.go","internal/httpserve/handlers/tools_test.go","pkg/keygen/auth_test.go","pkg/utils/oas/","pkg/keygen/crypto_test.go","pkg/auth/auth_test.go"] +ignore-hidden = true +ignore-files = true +ignore-dot = true +ignore-vcs = true +ignore-global = true +ignore-parent = true + +[default] +binary = false +check-filename = true +check-file = true +unicode = true +ignore-hex = true +identifier-leading-digits = false +locale = "en" +extend-ignore-identifiers-re = [] +extend-ignore-words-re = ["(?i)requestor","(?i)indentity","(?i)encrypter","(?i)seeked","(?i)generater"] +extend-ignore-re = ["#\\s*spellchecker:off\\s*\\n.*\\n\\s*#\\s*spellchecker:on"] \ No newline at end of file diff --git a/.yamlfmt b/.yamlfmt new file mode 100644 index 0000000..278d02f --- /dev/null +++ b/.yamlfmt @@ -0,0 +1,2 @@ +formatter: + retain_line_breaks: true \ No newline at end of file diff --git a/LICENSE b/LICENSE index 261eeb9..a3d69de 100644 --- a/LICENSE +++ b/LICENSE @@ -186,7 +186,7 @@ same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright [yyyy] [name of copyright owner] + Copyright [2024] [The Open Lane, Inc.] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/README.md b/README.md index e09f5ff..7c0389e 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,132 @@ +[![Build status](https://badge.buildkite.com/1c0fe32b0237364c58c977eabde2e01416fe075cb23e72c2aa.svg)](https://buildkite.com/theopenlane/go-turso) +[![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=theopenlane_go-turso&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=theopenlane_go-turso) + # go-turso -a go wrapper for working with Turso database services + +Golang client for interacting with the Turso Platform API. + +Currently supports the following endpoints: + +1. `Organizations`: `List` +1. `Groups`: `List`, `Get`, `Create`, `Delete` +1. `Databases`: `List`, `Get`, `Create`, `Delete` +1. `Database Locations`: `Add`, `Remove` +1. `Database Tokens`: `Create` + +## Usage + +``` +go get github.com/theopenlane/go-turso +``` + +```go +package main + +import ( + "context" + "fmt" + "log" + "os" + + "github.com/theopenlane/go-turso" +) + +func main() { + // setup the configuration + apiToken := os.Getenv("TURSO_API_TOKEN") + config := turso.Config{ + Token: apiToken, + BaseURL: "https://api.turso.tech", + OrgName: "theopenlane", + } + + // create the Turso Client + tc, err := turso.NewClient(config) + if err != nil { + log.Fatalf("failed to initialize turso client", "error", err) + } + + // List All Groups + groups, err := tc.Group.ListGroups(context.Background()) + if err != nil { + fmt.Println("error listing groups:", err) + + return + } + + for _, group := range groups.Groups { + fmt.Println("Group:", group.Name) + } + + // List All Databases + databases, err := tc.Database.ListDatabases(context.Background()) + if err != nil { + fmt.Println("error listing databases:", err) + + return + } + + for _, database := range databases.Databases { + fmt.Println("Database:", database.Name) + } + + // List All Organizations + orgs, err := tc.Organization.ListOrganizations(context.Background()) + if err != nil { + fmt.Println("error listing organizations:", err) + + return + } + + for _, org := range *orgs { + fmt.Println("Organization:", org.Name, org.Slug) + } + + // Create a new Group + g := turso.CreateGroupRequest{ + Name: "test-group", + Location: "ord", + } + + group, err := tc.Group.CreateGroup(context.Background(), g) + if err != nil { + fmt.Println("error creating group:", err) + } + + fmt.Println("Group Created:", group.Group.Name, group.Group.Locations) + + // Delete the Group + deletedGroup, err := tc.Group.DeleteGroup(context.Background(), g.Name) + if err != nil { + fmt.Println("error deleting group:", err) + } + + fmt.Println("Group Deleted:", deletedGroup.Group.Name, deletedGroup.Group.Locations) + + // Create a new Database + d := turso.CreateDatabaseRequest{ + Group: "default", + IsSchema: false, + Name: "test-database", + } + + database, err := tc.Database.CreateDatabase(context.Background(), d) + if err != nil { + fmt.Println("error creating database:", err) + } + + fmt.Println("Database Created:", database.Database.Name) + + // Delete the Database + deletedDatabase, err := tc.Database.DeleteDatabase(context.Background(), d.Name) + if err != nil { + fmt.Println("error deleting database:", err) + } + + fmt.Println("Database Deleted:", deletedDatabase.Database) +} +``` + +## References + +1. [Turso Platform API](https://docs.turso.tech/api-reference/introduction) \ No newline at end of file diff --git a/Taskfile.yaml b/Taskfile.yaml new file mode 100644 index 0000000..f0cdea4 --- /dev/null +++ b/Taskfile.yaml @@ -0,0 +1,33 @@ +version: '3' + +tasks: + go:lint: + desc: runs golangci-lint, the most annoying opinionated linter ever + cmds: + - golangci-lint run --config=.golangci.yaml --verbose + + go:test: + desc: runs and outputs results of created go tests + cmds: + - go test -v ./... + + go:tidy: + desc: runs go mod tidy + aliases: [tidy] + cmds: + - go mod tidy + + go:all: + aliases: [go] + desc: runs all go test and lint related tasks + cmds: + - task: go:tidy + - task: go:lint + - task: go:test + + precommit-full: + desc: Lint the project against all files + cmds: + - pre-commit install && pre-commit install-hooks + - pre-commit autoupdate + - pre-commit run --show-diff-on-failure --color=always --all-files diff --git a/client.go b/client.go new file mode 100644 index 0000000..d9b9c48 --- /dev/null +++ b/client.go @@ -0,0 +1,84 @@ +package turso + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" +) + +// Client manages communication with the Turso API +type Client struct { + cfg *Config + client HTTPRequestDoer + // Reuse a single struct instead of allocating one for each service on the heap + common service + // Services + Organization organizationService + Group groupService + Database databaseService + DatabaseTokens databaseTokensService +} + +type service struct { + client *Client +} + +// HTTPRequestDoer implements the standard http.Client interface +type HTTPRequestDoer interface { + Do(req *http.Request) (*http.Response, error) +} + +// Client returns the http client +func (c *Client) Client() HTTPRequestDoer { + return c.client +} + +// NewClient creates a new client for interacting with the Turso API +func NewClient(c Config) (*Client, error) { + if c.Token == "" { + return nil, ErrAPITokenNotSet + } + + client := &Client{ + cfg: &c, + } + + if client.client == nil { + client.client = http.DefaultClient + } + + client.common.client = client + + // initialize services + client.Organization = (*OrganizationService)(&client.common) + client.Database = (*DatabaseService)(&client.common) + client.Group = (*GroupService)(&client.common) + client.DatabaseTokens = (*DatabaseTokensService)(&client.common) + + return client, nil +} + +// DoRequest performs an HTTP request and returns the response +func (c *Client) DoRequest(ctx context.Context, method string, url string, data interface{}) (*http.Response, error) { + var bodyReader io.Reader + + buf, err := json.Marshal(data) + if err != nil { + return nil, err + } + + bodyReader = bytes.NewReader(buf) + + req, err := http.NewRequestWithContext(ctx, method, url, bodyReader) + if err != nil { + return nil, err + } + + // Add Headers + req.Header.Add("Authorization", "Bearer "+c.cfg.Token) + req.Header.Add("Content-Type", "application/json") + + return c.client.Do(req.WithContext(ctx)) +} diff --git a/config.go b/config.go new file mode 100644 index 0000000..9dc6b82 --- /dev/null +++ b/config.go @@ -0,0 +1,11 @@ +package turso + +// Config is the configuration for the turso client +type Config struct { + // Token is the token used to authenticate with the turso API + Token string `json:"token" koanf:"token" jsonschema:"required"` + // BaseURL is the base URL for the turso API + BaseURL string `json:"baseUrl" koanf:"baseUrl" jsonschema:"required" default:"https://api.turso.tech"` + // OrgName is the name of the organization to use for the turso API + OrgName string `json:"orgName" koanf:"orgName" jsonschema:"required"` +} diff --git a/database.go b/database.go new file mode 100644 index 0000000..60fde30 --- /dev/null +++ b/database.go @@ -0,0 +1,234 @@ +package turso + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "regexp" +) + +const ( + databaseEndpoint = "v1/organizations/%s/databases" + maxNameLength = 32 + regexName = "^[a-z0-9-]+$" +) + +// DatabaseService is the interface for the Turso API database endpoint +type DatabaseService service + +type databaseService interface { + // ListDatabases lists all databases in the organization + ListDatabases(ctx context.Context) (*ListDatabaseResponse, error) + // CreateDatabase creates a new database + CreateDatabase(ctx context.Context, req CreateDatabaseRequest) (*CreateDatabaseResponse, error) + // GetDatabase gets a database by name + GetDatabase(ctx context.Context, dbName string) (*GetDatabaseResponse, error) + // DeleteDatabase deletes a database by name + DeleteDatabase(ctx context.Context, dbName string) (*DeleteDatabaseResponse, error) +} + +// Database is the struct for the Turso Database object +type Database struct { + // Name is the name of the database + Name string `json:"Name"` + // DatabaseID is the ID of the database + DatabaseID string `json:"DbId"` + // Hostname is the hostname of the database` + Hostname string `json:"Hostname"` // this is in the response twice, once with a capital H and once with a lowercase h + // IsSchema is this database controls other child databases then this will be true + IsSchema bool `json:"is_schema"` + // Schema is the name of the parent database that owns the schema for this database + Schema string `json:"schema"` + // BlockedReads is true if reads are blocked + BlockReads bool `json:"block_reads"` + // BlockedWrites is true if writes are blocked + BlockWrites bool `json:"block_writes"` + // AllowAttach is true if the database allows attachments of a child database + AllowAttach bool `json:"allow_attach"` + // Regions is a list of regions the database is available in + Regions []string `json:"regions"` + // PrimaryRegion is the primary region for the database + PrimaryRegion string `json:"primaryRegion"` + // Type is the type of the database + Type string `json:"type"` + // Version is the version of libsql used by the database + Version string `json:"version"` + // Group is the group the database is in + Group string `json:"group"` + // Sleeping is true if the database is sleeping + Sleeping bool `json:"sleeping"` +} + +// CreateDatabase is the struct for the Turso API database create request +type CreateDatabase struct { + // DatabaseID is the ID of the database + DatabaseID string `json:"DbId"` + // Name is the name of the database + Name string `json:"Name"` + // Hostname is the hostname of the database + Hostname string `json:"Hostname"` + // IssuedCertCount is the number of certificates issued + IssuedCertCount int `json:"IssuedCertCount"` + // IssuedCertLimit is the limit of certificates that can be issued + IssuedCertLimit int `json:"IssuedCertLimit"` +} + +// ListDatabaseResponse is the struct for the Turso API database list response +type ListDatabaseResponse struct { + Databases []*Database `json:"databases"` +} + +// GetDatabaseResponse is the struct for the Turso API database get response +type GetDatabaseResponse struct { + Database *Database `json:"database"` +} + +// CreateDatabaseResponse is the struct for the Turso API database create response +type CreateDatabaseResponse struct { + Database CreateDatabase `json:"database"` +} + +// DeleteDatabaseResponse is the struct for the Turso API database delete response +type DeleteDatabaseResponse struct { + Database string `json:"database"` +} + +// CreateDatabaseRequest is the struct for the Turso API database create request +type CreateDatabaseRequest struct { + // Group is the group the database is in + Group string `json:"group"` + // IsSchema is this database controls other child databases then this will be true + IsSchema bool `json:"is_schema"` + // Name is the name of the database + // Must contain only lowercase letters, numbers, dashes. No longer than 32 characters. + Name string `json:"name"` +} + +// getDatabaseEndpoint returns the endpoint for the Turso API database service +func getDatabaseEndpoint(baseURL, orgName string) string { + dbEndpoint := fmt.Sprintf(databaseEndpoint, orgName) + return fmt.Sprintf("%s/%s", baseURL, dbEndpoint) +} + +// CreateDatabase satisfies the databaseService interface +func (s *DatabaseService) CreateDatabase(ctx context.Context, db CreateDatabaseRequest) (*CreateDatabaseResponse, error) { + // Sanitize the database name + if err := validateDatabaseName(db.Name); err != nil { + return nil, err + } + + // Create the database + endpoint := getDatabaseEndpoint(s.client.cfg.BaseURL, s.client.cfg.OrgName) + + resp, err := s.client.DoRequest(ctx, http.MethodPost, endpoint, db) + if err != nil { + return nil, err + } + + defer resp.Body.Close() + + // Decode the response + var out CreateDatabaseResponse + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return nil, err + } + + if resp.StatusCode != http.StatusOK { + return nil, newBadRequestError("database", "creating", resp.StatusCode) + } + + return &out, nil +} + +// ListDatabases satisfies the databaseService interface +func (s *DatabaseService) ListDatabases(ctx context.Context) (*ListDatabaseResponse, error) { + endpoint := getDatabaseEndpoint(s.client.cfg.BaseURL, s.client.cfg.OrgName) + + resp, err := s.client.DoRequest(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, err + } + + defer resp.Body.Close() + + var out ListDatabaseResponse + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return nil, err + } + + if resp.StatusCode != http.StatusOK { + return nil, newBadRequestError("databases", "listing", resp.StatusCode) + } + + return &out, nil +} + +// GetDatabase satisfies the databaseService interface +func (s *DatabaseService) GetDatabase(ctx context.Context, dbName string) (*GetDatabaseResponse, error) { + // get endpoint and append the database name + endpoint := getDatabaseEndpoint(s.client.cfg.BaseURL, s.client.cfg.OrgName) + endpoint = fmt.Sprintf("%s/%s", endpoint, dbName) + + resp, err := s.client.DoRequest(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, err + } + + defer resp.Body.Close() + + var out *GetDatabaseResponse + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return nil, err + } + + if resp.StatusCode != http.StatusOK { + return nil, newBadRequestError("database", "getting", resp.StatusCode) + } + + return out, nil +} + +// DeleteDatabase satisfies the databaseService interface +func (s *DatabaseService) DeleteDatabase(ctx context.Context, dbName string) (*DeleteDatabaseResponse, error) { + // Delete the database + endpoint := getDatabaseEndpoint(s.client.cfg.BaseURL, s.client.cfg.OrgName) + endpoint = fmt.Sprintf("%s/%s", endpoint, dbName) + + resp, err := s.client.DoRequest(ctx, http.MethodDelete, endpoint, nil) + if err != nil { + return nil, err + } + + defer resp.Body.Close() + + // Decode the response + var out DeleteDatabaseResponse + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return nil, err + } + + if resp.StatusCode != http.StatusOK { + return nil, newBadRequestError("database", "deleting", resp.StatusCode) + } + + return &out, nil +} + +// validateDatabaseName validates the database name to ensure it meets the requirements set by the Turso API +func validateDatabaseName(name string) error { + match, err := regexp.MatchString(regexName, name) + if err != nil { + return err + } + + if !match { + return ErrInvalidDatabaseName + } + + if len(name) > maxNameLength { + return ErrInvalidDatabaseName + } + + return nil +} diff --git a/database_test.go b/database_test.go new file mode 100644 index 0000000..749163c --- /dev/null +++ b/database_test.go @@ -0,0 +1,102 @@ +package turso + +import ( + "bytes" + "context" + "io" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestListDatabases(t *testing.T) { + databaseService := newMockDatabaseService() + + resp, err := databaseService.ListDatabases(context.Background()) + require.NoError(t, err) + assert.Len(t, resp.Databases, 1) +} +func TestGetDatabase(t *testing.T) { + databaseService := newMockDatabaseService() + + resp, err := databaseService.GetDatabase(context.Background(), "my-db") + require.NoError(t, err) + assert.Equal(t, resp.Database.Name, "my-db") +} + +func TestDeleteDatabase(t *testing.T) { + databaseService := newMockDatabaseService() + + resp, err := databaseService.DeleteDatabase(context.Background(), "my-db") + require.NoError(t, err) + assert.Equal(t, resp.Database, "my-db") +} + +func TestCreateDatabase(t *testing.T) { + body := `{"database":{"DbId":"0eb771dd-6906-11ee-8553-eaa7715aeaf2","Hostname":"[databaseName]-[organizationName].turso.io","Name":"my-db"}}` + client := &Client{ + cfg: &Config{ + BaseURL: "http://localhost", + }, + client: &MockHTTPRequestDoer{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewReader([]byte(body))), + }, + }, + } + + // happy path + databaseService := DatabaseService{client: client} + req := CreateDatabaseRequest{ + Name: "my-db", + } + + resp, err := databaseService.CreateDatabase(context.Background(), req) + require.NoError(t, err) + assert.Equal(t, resp.Database.Name, "my-db") + + // test error + req = CreateDatabaseRequest{ + Name: "myAWESOMEdb", + } + + resp, err = databaseService.CreateDatabase(context.Background(), req) + assert.Error(t, err) + assert.Nil(t, resp) +} + +func TestValidateDatabaseName(t *testing.T) { + tests := []struct { + name string + input string + expectErr bool + }{ + { + name: "happy path, simple name", + input: "mydatabase-123", + expectErr: false, + }, + { + name: "name with uppercase", + input: "ORG-123ABC", + expectErr: true, + }, + { + name: "long name", + input: "MECPJTpHtBEyUNBAujXw6mxCjN4ARLPJ3", + expectErr: true, + }, + } + + for _, test := range tests { + result := validateDatabaseName(test.input) + if test.expectErr { + require.Error(t, result) + } else { + require.NoError(t, result) + } + } +} diff --git a/database_tokens.go b/database_tokens.go new file mode 100644 index 0000000..6aa5645 --- /dev/null +++ b/database_tokens.go @@ -0,0 +1,139 @@ +package turso + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + + "github.com/xhit/go-str2duration/v2" +) + +const ( + databaseTokensEndpoint = "v1/organizations/%s/databases/%s/auth/tokens" + FullAccess = "full-access" + ReadOnly = "read-only" + DefaultExpiration = "never" +) + +var validAuthorization = []string{FullAccess, ReadOnly} + +// DatabaseTokensService is the interface for the Turso API database tokens service +type DatabaseTokensService service + +type databaseTokensService interface { + // CreateDatabaseToken creates a new database token + CreateDatabaseToken(ctx context.Context, req CreateDatabaseTokenRequest) (*CreateDatabaseTokenResponse, error) +} + +// CreateDatabaseTokenRequest is the struct for the Turso API database token create request +type CreateDatabaseTokenRequest struct { + // DatabaseName is the name of the database + DatabaseName string + // Expiration is the expiration time for the token + Expiration string + // Permissions is the permissions for the token + Authorization string + // ReadAttach permission for the token + AttachPermissions Permissions `json:"permissions"` +} + +type Permissions struct { + ReadAttach struct { + Database []string `json:"database"` + } `json:"read_attach"` +} + +// CreateDatabaseTokenResponse is the struct for the Turso API database token create response +type CreateDatabaseTokenResponse struct { + JWT string `json:"jwt"` +} + +// InvalidateDatabaseTokenRequest is the struct for the Turso API database token invalidate request +type InvalidateDatabaseTokenRequest struct { + // DatabaseName is the name of the database + DatabaseName string `json:"database_name"` +} + +// getDatabaseTokensEndpoint returns the endpoint for the Turso API database token service +func getDatabaseTokensEndpoint(baseURL, orgName, dbName string) string { + dbEndpoint := fmt.Sprintf(databaseTokensEndpoint, orgName, dbName) + return fmt.Sprintf("%s/%s", baseURL, dbEndpoint) +} + +// CreateDatabaseToken satisfies the databaseTokensService interface +func (s *DatabaseTokensService) CreateDatabaseToken(ctx context.Context, req CreateDatabaseTokenRequest) (*CreateDatabaseTokenResponse, error) { + if err := validateDatabaseTokenRequest(req); err != nil { + return nil, err + } + + endpoint := getDatabaseTokensEndpoint(s.client.cfg.BaseURL, s.client.cfg.OrgName, req.DatabaseName) + endpoint = fmt.Sprintf("%s?expiration=%s&authorization=%s", endpoint, req.Expiration, req.Authorization) + + resp, err := s.client.DoRequest(ctx, http.MethodPost, endpoint, req) + if err != nil { + return nil, err + } + + defer resp.Body.Close() + + var out CreateDatabaseTokenResponse + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return nil, err + } + + if resp.StatusCode != http.StatusOK { + return nil, newBadRequestError("database token", "creating", resp.StatusCode) + } + + return &out, nil +} + +// validateDatabaseTokenRequest ensures the authorization and expiration are valid +// in the given request before making the API call +func validateDatabaseTokenRequest(req CreateDatabaseTokenRequest) error { + if !isValidExpiration(req.Expiration) { + return ErrExpirationInvalid + } + + if !isValidAuthorization(req.Authorization) { + return ErrAuthorizationInvalid + } + + return nil +} + +// IsValidExpiration checks if the expiration is valid +func isValidExpiration(expiration string) bool { + // check for empty fields first + if expiration == "" { + return false + } + + if expiration == DefaultExpiration { + return true + } + + if _, err := str2duration.ParseDuration(expiration); err != nil { + return false + } + + return true +} + +// IsValidAuthorization checks if the authorization is valid +func isValidAuthorization(authorization string) bool { + // check for empty fields first + if authorization == "" { + return false + } + + // check for valid authorization + for _, v := range validAuthorization { + if v == authorization { + return true + } + } + + return false +} diff --git a/database_tokens_test.go b/database_tokens_test.go new file mode 100644 index 0000000..40e1e13 --- /dev/null +++ b/database_tokens_test.go @@ -0,0 +1,130 @@ +package turso + +import ( + "bytes" + "context" + "io" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCreateDatabaseToken(t *testing.T) { + body := `{"jwt": "areallylongstringjwtgoeshere"}` + client := &Client{ + cfg: &Config{ + BaseURL: "http://localhost", + }, + client: &MockHTTPRequestDoer{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewReader([]byte(body))), + }, + }, + } + + // happy path + databaseTokenService := DatabaseTokensService{client: client} + req := CreateDatabaseTokenRequest{ + DatabaseName: "my-db", + Expiration: "1h30m", + Authorization: FullAccess, + } + + resp, err := databaseTokenService.CreateDatabaseToken(context.Background(), req) + require.NoError(t, err) + assert.NotNil(t, resp.JWT) + + // test errors + req = CreateDatabaseTokenRequest{ + DatabaseName: "my-db", + Authorization: FullAccess, + } + + resp, err = databaseTokenService.CreateDatabaseToken(context.Background(), req) + assert.Error(t, err) + assert.Nil(t, resp) + + req = CreateDatabaseTokenRequest{ + DatabaseName: "my-db", + Expiration: "1h30m", + Authorization: "invalid", + } + + resp, err = databaseTokenService.CreateDatabaseToken(context.Background(), req) + assert.Error(t, err) + assert.Nil(t, resp) +} + +func TestValidateDatabaseTokenRequest(t *testing.T) { + tests := []struct { + name string + request CreateDatabaseTokenRequest + wantErr error + }{ + { + name: "Valid request, full access", + request: CreateDatabaseTokenRequest{ + Expiration: "never", + Authorization: "full-access", + }, + wantErr: nil, + }, + { + name: "Valid request, read only", + request: CreateDatabaseTokenRequest{ + Expiration: "never", + Authorization: "read-only", + }, + wantErr: nil, + }, + { + name: "Valid request, with duration", + request: CreateDatabaseTokenRequest{ + Expiration: "12w", + Authorization: "read-only", + }, + wantErr: nil, + }, + { + name: "Missing expiration", + request: CreateDatabaseTokenRequest{ + Expiration: "", + Authorization: "read-only", + }, + wantErr: ErrExpirationInvalid, + }, + { + name: "Invalid authorization", + request: CreateDatabaseTokenRequest{ + Expiration: "never", + Authorization: "invalid", + }, + wantErr: ErrAuthorizationInvalid, + }, + { + name: "Invalid expiration", + request: CreateDatabaseTokenRequest{ + Expiration: "2030-01-01", + Authorization: "invalid", + }, + wantErr: ErrExpirationInvalid, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateDatabaseTokenRequest(tt.request) + if tt.wantErr != nil { + require.Error(t, err) + assert.ErrorIs(t, err, tt.wantErr) + + return + } + + require.NoError(t, err) + }) + } +} diff --git a/doc.go b/doc.go new file mode 100644 index 0000000..891c5b2 --- /dev/null +++ b/doc.go @@ -0,0 +1,2 @@ +// Package turso provides a client for interacting with the Turso API. +package turso diff --git a/errors.go b/errors.go new file mode 100644 index 0000000..c556c88 --- /dev/null +++ b/errors.go @@ -0,0 +1,81 @@ +package turso + +import ( + "errors" + "fmt" +) + +var ( + // ErrAPITokenNotSet is returned when the API token is not set + ErrAPITokenNotSet = errors.New("api token not set, but required") + + // ErrInvalidDatabaseName is returned when a database name is invalid + ErrInvalidDatabaseName = errors.New("invalid database name, can only contain lowercase letters, numbers, dashes with a maximum of 32 characters") + + // ErrExpirationNotSet is returned when the expiration is not set + ErrExpirationInvalid = errors.New("expiration invalid, must be a valid duration (e.g. 12w) or never") + + // ErrAuthorizationInvalid is returned when the authorization is invalid + ErrAuthorizationInvalid = errors.New("authorization invalid, valid options are full-access or read-only") +) + +// TursoError is returned when a request to the Turso API fails +type TursoError struct { + // Object is the object that the error occurred on + Object string + // Method is the method that the error occurred in + Method string + // Status is the status code of the error + Status int +} + +// Error returns the RequiredFieldMissingError in string format +func (e *TursoError) Error() string { + return fmt.Sprintf("error %s %s: %d", e.Method, e.Object, e.Status) +} + +// newBadRequestError returns an error a bad request +func newBadRequestError(object, method string, status int) *TursoError { + return &TursoError{ + Object: object, + Method: method, + Status: status, + } +} + +// MissingRequiredFieldError is returned when a required field was not provided in a request +type MissingRequiredFieldError struct { + // RequiredField that is missing + RequiredField string +} + +// Error returns the MissingRequiredFieldError in string format +func (e *MissingRequiredFieldError) Error() string { + return fmt.Sprintf("%s is required", e.RequiredField) +} + +// newMissingRequiredField returns an error for a missing required field +func newMissingRequiredFieldError(field string) *MissingRequiredFieldError { + return &MissingRequiredFieldError{ + RequiredField: field, + } +} + +// InvalidFieldError is returned when a required field does not meet the required criteria +type InvalidFieldError struct { + Field string + Message string +} + +// Error returns the InvalidFieldError in string format +func (e *InvalidFieldError) Error() string { + return fmt.Sprintf("%s is invalid, %s", e.Field, e.Message) +} + +// newMissingRequiredField returns an error for a missing required field +func newInvalidFieldError(field, message string) *InvalidFieldError { + return &InvalidFieldError{ + Field: field, + Message: message, + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..9a8f08e --- /dev/null +++ b/go.mod @@ -0,0 +1,14 @@ +module github.com/theopenlane/go-turso + +go 1.23.0 + +require ( + github.com/stretchr/testify v1.9.0 + github.com/xhit/go-str2duration/v2 v2.1.0 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..0d5f44a --- /dev/null +++ b/go.sum @@ -0,0 +1,12 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/xhit/go-str2duration/v2 v2.1.0 h1:lxklc02Drh6ynqX+DdPyp5pCKLUQpRT8bp8Ydu2Bstc= +github.com/xhit/go-str2duration/v2 v2.1.0/go.mod h1:ohY8p+0f07DiV6Em5LKB0s2YpLtXVyJfNt1+BlmyAsU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/group.go b/group.go new file mode 100644 index 0000000..9aa1955 --- /dev/null +++ b/group.go @@ -0,0 +1,307 @@ +package turso + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strings" +) + +const ( + groupEndpoint = "v1/organizations/%s/groups" + locationEndpoint = groupEndpoint + "%s/locations/%s" +) + +// GroupService is the interface for the Turso API group endpoint +type GroupService service + +type groupService interface { + // ListGroups lists all groups in the organization + ListGroups(ctx context.Context) (*ListGroupResponse, error) + // CreateGroup creates a new group in the organization + CreateGroup(ctx context.Context, req CreateGroupRequest) (*CreateGroupResponse, error) + // GetGroup gets a group by name + GetGroup(ctx context.Context, groupName string) (*GetGroupResponse, error) + // DeleteGroup deletes a group by name + DeleteGroup(ctx context.Context, groupName string) (*DeleteGroupResponse, error) + // AddLocation adds a location to a group + AddLocation(ctx context.Context, eq GroupLocationRequest) (*GroupLocationResponse, error) + // RemoveLocation removes a location from a group + RemoveLocation(ctx context.Context, req GroupLocationRequest) (*GroupLocationResponse, error) +} + +// Group is the struct for the Turso API group service +type Group struct { + Archived bool `json:"archived"` + Locations []string `json:"locations"` + Name string `json:"name"` + Primary string `json:"primary"` + UUID string `json:"uuid"` + Version string `json:"version"` +} + +// ListGroupResponse is the struct for the Turso API group list response +type ListGroupResponse struct { + Groups []Group `json:"groups"` +} + +// GetGroupResponse is the struct for the Turso API group get response +type GetGroupResponse struct { + Group Group `json:"group"` +} + +// CreateGroupResponse is the struct for the Turso API group create response +type CreateGroupResponse struct { + Group Group `json:"group"` +} + +// GroupLocationRequest is the struct for the Turso API to add or remove a location to a group request +type GroupLocationRequest struct { + // GroupName is the name of the group to add the location + GroupName string + // Location is the location to add to the group + Location string +} + +// GroupLocationResponse is the struct for the Turso API to add or remove location to group response +type GroupLocationResponse struct { + Group Group `json:"group"` +} + +// DeleteGroupResponse is the struct for the Turso API group delete response +type DeleteGroupResponse struct { + Group Group `json:"group"` +} + +// CreateGroupRequest is the struct for the Turso API group create request +type CreateGroupRequest struct { + Extensions string `json:"extensions"` + Location string `json:"location"` + Name string `json:"name"` +} + +// getGroupEndpoint returns the endpoint for the Turso API group service +func getGroupEndpoint(baseURL, orgName string) string { + groupEndpoint := fmt.Sprintf(groupEndpoint, orgName) + return fmt.Sprintf("%s/%s", baseURL, groupEndpoint) +} + +// getGroupLocationsEndpoint returns the endpoint for the Turso API group locations service +func getGroupLocationsEndpoint(baseURL, orgName, groupName, locationName string) string { + locEndpoint := fmt.Sprintf(locationEndpoint, orgName, groupName, locationName) + return fmt.Sprintf("%s/%s", baseURL, locEndpoint) +} + +// ListGroups satisfies the groupService interface +func (s *GroupService) ListGroups(ctx context.Context) (*ListGroupResponse, error) { + endpoint := getGroupEndpoint(s.client.cfg.BaseURL, s.client.cfg.OrgName) + + resp, err := s.client.DoRequest(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, err + } + + defer resp.Body.Close() + + var out ListGroupResponse + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return nil, err + } + + if resp.StatusCode != http.StatusOK { + return nil, newBadRequestError("groups", "listing", resp.StatusCode) + } + + return &out, nil +} + +// CreateGroup satisfies the groupService interface +func (s *GroupService) CreateGroup(ctx context.Context, group CreateGroupRequest) (*CreateGroupResponse, error) { + // Validate the request + if err := validateGroupCreateRequest(group); err != nil { + return nil, err + } + + // Create the group + endpoint := getGroupEndpoint(s.client.cfg.BaseURL, s.client.cfg.OrgName) + + resp, err := s.client.DoRequest(ctx, http.MethodPost, endpoint, group) + if err != nil { + return nil, err + } + + defer resp.Body.Close() + + // Decode the response + var out CreateGroupResponse + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return nil, err + } + + if resp.StatusCode != http.StatusOK { + return nil, newBadRequestError("group", "creating", resp.StatusCode) + } + + return &out, nil +} + +// GetGroup satisfies the groupService interface +func (s *GroupService) GetGroup(ctx context.Context, groupName string) (*GetGroupResponse, error) { + // get endpoint and append the group name + endpoint := getGroupEndpoint(s.client.cfg.BaseURL, s.client.cfg.OrgName) + endpoint = fmt.Sprintf("%s/%s", endpoint, groupName) + + resp, err := s.client.DoRequest(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, err + } + + defer resp.Body.Close() + + var out *GetGroupResponse + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return nil, err + } + + if resp.StatusCode != http.StatusOK { + return nil, newBadRequestError("group", "getting", resp.StatusCode) + } + + return out, nil +} + +// DeleteGroup satisfies the groupService interface +func (s *GroupService) DeleteGroup(ctx context.Context, groupName string) (*DeleteGroupResponse, error) { + // Create the group + endpoint := getGroupEndpoint(s.client.cfg.BaseURL, s.client.cfg.OrgName) + endpoint = fmt.Sprintf("%s/%s", endpoint, groupName) + + resp, err := s.client.DoRequest(ctx, http.MethodDelete, endpoint, nil) + if err != nil { + return nil, err + } + + defer resp.Body.Close() + + // Decode the response + var out DeleteGroupResponse + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return nil, err + } + + if resp.StatusCode != http.StatusOK { + return nil, newBadRequestError("group", "deleting", resp.StatusCode) + } + + return &out, nil +} + +// AddLocation satisfies the groupService interface +func (s *GroupService) AddLocation(ctx context.Context, req GroupLocationRequest) (*GroupLocationResponse, error) { + if err := validateLocationRequest(req); err != nil { + return nil, err + } + + endpoint := getGroupLocationsEndpoint(s.client.cfg.BaseURL, s.client.cfg.OrgName, req.GroupName, req.Location) + + resp, err := s.client.DoRequest(ctx, http.MethodPost, endpoint, nil) + if err != nil { + return nil, err + } + + defer resp.Body.Close() + + // Decode the response + var out GroupLocationResponse + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return nil, err + } + + if resp.StatusCode != http.StatusOK { + return nil, newBadRequestError("group", "creating", resp.StatusCode) + } + + return &out, nil +} + +// RemoveLocation satisfies the groupService interface +func (s *GroupService) RemoveLocation(ctx context.Context, req GroupLocationRequest) (*GroupLocationResponse, error) { + if err := validateLocationRequest(req); err != nil { + return nil, err + } + + endpoint := getGroupLocationsEndpoint(s.client.cfg.BaseURL, s.client.cfg.OrgName, req.GroupName, req.Location) + + resp, err := s.client.DoRequest(ctx, http.MethodDelete, endpoint, nil) + if err != nil { + return nil, err + } + + defer resp.Body.Close() + + // Decode the response + var out GroupLocationResponse + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return nil, err + } + + if resp.StatusCode != http.StatusOK { + return nil, newBadRequestError("group", "creating", resp.StatusCode) + } + + return &out, nil +} + +// validateGroupCreateRequest validates the group create request +func validateGroupCreateRequest(req CreateGroupRequest) error { + if err := validateGroupName(req.Name); err != nil { + return err + } + + if err := validateLocation(req.Location); err != nil { + return err + } + + return nil +} + +// validateGroupName validates the group name +func validateGroupName(groupName string) error { + if groupName == "" { + return newMissingRequiredFieldError("name") + } + + if strings.Contains(groupName, " ") { + return newInvalidFieldError("name", "spaces are not allowed") + } + + return nil +} + +// validateGroupName validates the group name +func validateLocation(location string) error { + if location == "" { + return newMissingRequiredFieldError("location") + } + + // all Turso locations are 3 characters + if len(location) != 3 { // nolint:mnd + return newInvalidFieldError("location", "must be 3 characters") + } + + return nil +} + +// validateLocationRequest validates the group location request fields +func validateLocationRequest(req GroupLocationRequest) error { + if req.GroupName == "" { + return newMissingRequiredFieldError("name") + } + + if req.Location == "" { + return newMissingRequiredFieldError("location") + } + + return nil +} diff --git a/group_test.go b/group_test.go new file mode 100644 index 0000000..2403b96 --- /dev/null +++ b/group_test.go @@ -0,0 +1,219 @@ +package turso + +import ( + "bytes" + "context" + "io" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestListGroups(t *testing.T) { + groupService := newMockGroupService() + + resp, err := groupService.ListGroups(context.Background()) + require.NoError(t, err) + assert.Len(t, resp.Groups, 1) +} +func TestGetGroup(t *testing.T) { + groupService := newMockGroupService() + + resp, err := groupService.GetGroup(context.Background(), "meow") + require.NoError(t, err) + assert.Equal(t, resp.Group.Name, "meow") +} + +func TestDeleteGroup(t *testing.T) { + groupService := newMockGroupService() + + resp, err := groupService.DeleteGroup(context.Background(), "meow") + require.NoError(t, err) + assert.Equal(t, resp.Group.Name, "woof") + assert.True(t, resp.Group.Archived) +} + +func TestCreateGroup(t *testing.T) { + body := `{"group":{"archived":false,"locations":["lhr","ams","bos"],"name":"meow","primary":"lhr","uuid":"0a28102d-6906-11ee-8553-eaa7715aeaf2","version":"v0.23.7"}}` + client := &Client{ + cfg: &Config{ + BaseURL: "http://localhost", + }, + client: &MockHTTPRequestDoer{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewReader([]byte(body))), + }, + }, + } + + // happy path + groupService := GroupService{client: client} + req := CreateGroupRequest{ + Name: "meow", + Location: "ams", + } + + resp, err := groupService.CreateGroup(context.Background(), req) + require.NoError(t, err) + assert.Equal(t, resp.Group.Name, "meow") + + // test error + req = CreateGroupRequest{} + + resp, err = groupService.CreateGroup(context.Background(), req) + assert.Error(t, err) + assert.Nil(t, resp) +} + +func TestAddLocation(t *testing.T) { + body := `{"group":{"archived":false,"locations":["lhr","ams","bos", "den"],"name":"meow","primary":"lhr","uuid":"0a28102d-6906-11ee-8553-eaa7715aeaf2","version":"v0.23.7"}}` + client := &Client{ + cfg: &Config{ + BaseURL: "http://localhost", + }, + client: &MockHTTPRequestDoer{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewReader([]byte(body))), + }, + }, + } + + // happy path + groupService := GroupService{client: client} + req := GroupLocationRequest{ + GroupName: "meow", + Location: "den", + } + + resp, err := groupService.AddLocation(context.Background(), req) + require.NoError(t, err) + assert.Equal(t, resp.Group.Name, "meow") + + // test error, missing location + req = GroupLocationRequest{ + GroupName: "meow", + } + + resp, err = groupService.AddLocation(context.Background(), req) + assert.Error(t, err) + assert.Nil(t, resp) + + // test error, missing group name + req = GroupLocationRequest{ + Location: "den", + } + + resp, err = groupService.AddLocation(context.Background(), req) + assert.Error(t, err) + assert.Nil(t, resp) +} + +func TestRemoveLocation(t *testing.T) { + body := `{"group":{"archived":false,"locations":["lhr","ams","bos"] ,"name":"meow","primary":"lhr","uuid":"0a28102d-6906-11ee-8553-eaa7715aeaf2","version":"v0.23.7"}}` + client := &Client{ + cfg: &Config{ + BaseURL: "http://localhost", + }, + client: &MockHTTPRequestDoer{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewReader([]byte(body))), + }, + }, + } + + // happy path + groupService := GroupService{client: client} + req := GroupLocationRequest{ + GroupName: "meow", + Location: "den", + } + + resp, err := groupService.RemoveLocation(context.Background(), req) + require.NoError(t, err) + assert.Equal(t, resp.Group.Name, "meow") + + // test error, missing location + req = GroupLocationRequest{ + GroupName: "meow", + } + + resp, err = groupService.RemoveLocation(context.Background(), req) + assert.Error(t, err) + assert.Nil(t, resp) + + // test error, missing group name + req = GroupLocationRequest{ + Location: "den", + } + + resp, err = groupService.RemoveLocation(context.Background(), req) + assert.Error(t, err) + assert.Nil(t, resp) +} +func TestValidateGroupCreateRequest(t *testing.T) { + tests := []struct { + name string + request CreateGroupRequest + wantErr error + }{ + { + name: "Valid request", + request: CreateGroupRequest{ + Name: "meow", + Location: "ams", + }, + wantErr: nil, + }, + { + name: "missing name", + request: CreateGroupRequest{ + Name: "", + Location: "ams", + }, + wantErr: &MissingRequiredFieldError{RequiredField: "name"}, + }, + { + name: "invalid name", + request: CreateGroupRequest{ + Name: "my group", + Location: "ams", + }, + wantErr: &InvalidFieldError{Field: "name", Message: "spaces are not allowed"}, + }, + { + name: "missing location", + request: CreateGroupRequest{ + Name: "the-best", + Location: "", + }, + wantErr: &MissingRequiredFieldError{RequiredField: "location"}, + }, + { + name: "invalid location", + request: CreateGroupRequest{ + Name: "the-best", + Location: "us", + }, + wantErr: &InvalidFieldError{Field: "location", Message: "must be 3 characters"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateGroupCreateRequest(tt.request) + if tt.wantErr != nil { + require.Error(t, err) + assert.ErrorContains(t, err, tt.wantErr.Error()) + + return + } + + require.NoError(t, err) + }) + } +} diff --git a/organization.go b/organization.go new file mode 100644 index 0000000..5e483dc --- /dev/null +++ b/organization.go @@ -0,0 +1,60 @@ +package turso + +import ( + "context" + "encoding/json" + "fmt" + "net/http" +) + +const ( + organizationEndpoint = "v1/organizations" +) + +type OrganizationService service + +type organizationService interface { + // ListOrganizations lists all organizations for the authorized user + ListOrganizations(ctx context.Context) (*[]Organization, error) +} + +// Organization is the struct for the Turso Organization object +type Organization struct { + Name string `json:"name"` + Slug string `json:"slug"` + Type string `json:"type"` + PlanID string `json:"plan_id"` + Overages bool `json:"overages"` + BlockedReads bool `json:"blocked_reads"` + BlockedWrites bool `json:"blocked_writes"` + PlanTimeline string `json:"plan_timeline"` + Memory int `json:"memory"` +} + +// getOrganizationEndpoint returns the endpoint for the Turso API organization service +func getOrganizationEndpoint(baseURL string) string { + return fmt.Sprintf("%s/%s", baseURL, organizationEndpoint) +} + +// ListOrganizations satisfies the organizationService interface +func (s *OrganizationService) ListOrganizations(ctx context.Context) (*[]Organization, error) { + endpoint := getOrganizationEndpoint(s.client.cfg.BaseURL) + + resp, err := s.client.DoRequest(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, err + } + + defer resp.Body.Close() + + var out []Organization + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return nil, err + } + + if resp.StatusCode != http.StatusOK { + return nil, newBadRequestError("organizations", "listing", resp.StatusCode) + } + + return &out, nil +} diff --git a/organization_test.go b/organization_test.go new file mode 100644 index 0000000..8be5b3a --- /dev/null +++ b/organization_test.go @@ -0,0 +1,17 @@ +package turso + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestListOrganizations(t *testing.T) { + orgService := newMockOrganizationService() + + resp, err := orgService.ListOrganizations(context.Background()) + require.NoError(t, err) + assert.Len(t, *resp, 1) +} diff --git a/renovate.json b/renovate.json new file mode 100644 index 0000000..ec944db --- /dev/null +++ b/renovate.json @@ -0,0 +1,11 @@ +{ + "extends": [ + "config:base" + ], + "postUpdateOptions": [ + "gomodTidy" + ], + "labels": [ + "dependencies" + ] +} \ No newline at end of file diff --git a/sonar-project.properties b/sonar-project.properties new file mode 100644 index 0000000..a26f476 --- /dev/null +++ b/sonar-project.properties @@ -0,0 +1,16 @@ +sonar.projectKey=theopenlane_go-turso +sonar.organization=theopenlane + +sonar.projectName=go-turso +sonar.projectVersion=1.0 + +sonar.sources=. + +sonar.exclusions= +sonar.tests=. +sonar.test.inclusions=*_test.go +sonar.exclusions= + +sonar.sourceEncoding=UTF-8 +sonar.go.coverage.reportPaths=coverage.out +sonar.externalIssuesReportPaths=results.txt \ No newline at end of file diff --git a/test_tools.go b/test_tools.go new file mode 100644 index 0000000..716dbfb --- /dev/null +++ b/test_tools.go @@ -0,0 +1,215 @@ +package turso + +import ( + "context" + "net/http" +) + +// MockHTTPRequestDoer implements the standard http.Client interface. +type MockHTTPRequestDoer struct { + Response *http.Response + Error error +} + +// Do implements the standard http.Client interface for MockHTTPRequestDoer +func (md *MockHTTPRequestDoer) Do(req *http.Request) (*http.Response, error) { + return md.Response, md.Error +} + +// NewMockClient creates a new client for interacting with the Turso API to mock ok requests +// this can be used to test the client without hitting the actual API an expect an 200 OK response. +func NewMockClient() *Client { + c := &Client{} + c.Group = newMockGroupService() + c.Database = newMockDatabaseService() + c.Organization = newMockOrganizationService() + c.DatabaseTokens = newMockDatabaseTokenService() + + return c +} + +type MockGroupService struct { + ListGroupResponse *ListGroupResponse + CreateGroupResponse *CreateGroupResponse + GetGroupResponse *GetGroupResponse + DeleteGroupResponse *DeleteGroupResponse + GroupLocationResponse *GroupLocationResponse + Error error +} + +type MockDatabaseService struct { + ListDatabaseResponse *ListDatabaseResponse + CreateDatabaseResponse *CreateDatabaseResponse + GetDatabaseResponse *GetDatabaseResponse + DeleteDatabaseResponse *DeleteDatabaseResponse + Error error +} + +type MockDatabaseTokensService struct { + CreateDatabaseTokenResponse *CreateDatabaseTokenResponse + Error error +} + +type MockOrganizationService struct { + ListOrganizationsResponse *[]Organization + Error error +} + +func newMockGroupService() groupService { + return &MockGroupService{ + ListGroupResponse: &ListGroupResponse{ + Groups: []Group{ + { + Archived: false, + Locations: []string{"lhr", "ams", "bos"}, + Name: "meow", + Primary: "lhr", + UUID: "0a28102d-6906-11ee-8553-eaa7715aeaf2", + Version: "v0.23.7", + }, + }, + }, + CreateGroupResponse: &CreateGroupResponse{ + Group: Group{ + Archived: false, + Locations: []string{"lhr", "ams", "bos"}, + Name: "meow", + Primary: "lhr", + UUID: "0a28102d-6906-11ee-8553-eaa7715aeaf2", + Version: "v0.23.7", + }, + }, + GetGroupResponse: &GetGroupResponse{ + Group: Group{ + Archived: false, + Locations: []string{"lhr", "ams", "bos"}, + Name: "meow", + Primary: "lhr", + UUID: "0a28102d-6906-11ee-8553-eaa7715aeaf2", + Version: "v0.23.7", + }, + }, + DeleteGroupResponse: &DeleteGroupResponse{ + Group: Group{ + Archived: true, + Locations: []string{"lhr", "ams", "bos"}, + Name: "woof", + Primary: "lhr", + UUID: "0a28102d-6906-11ee-8553-eaa7715aeaf2", + Version: "v0.23.7", + }, + }, + GroupLocationResponse: &GroupLocationResponse{ + Group: Group{ + Archived: false, + Locations: []string{"lhr", "ams", "bos"}, + Name: "meow", + Primary: "lhr", + UUID: "0a28102d-6906-11ee-8553-eaa7715aeaf2", + Version: "v0.23.7", + }, + }, + Error: nil, + } +} + +func newMockDatabaseService() databaseService { + return &MockDatabaseService{ + ListDatabaseResponse: &ListDatabaseResponse{ + Databases: []*Database{ + { + Name: "my-db", + Hostname: "[databaseName]-[organizationName].turso.io", + DatabaseID: "0eb771dd-6906-11ee-8553-eaa7715aeaf2", + }, + }, + }, + CreateDatabaseResponse: &CreateDatabaseResponse{ + CreateDatabase{ + Name: "my-db", + Hostname: "[databaseName]-[organizationName].turso.io", + DatabaseID: "0eb771dd-6906-11ee-8553-eaa7715aeaf2", + }, + }, + GetDatabaseResponse: &GetDatabaseResponse{ + Database: &Database{ + Name: "my-db", + Hostname: "[databaseName]-[organizationName].turso.io", + DatabaseID: "0eb771dd-6906-11ee-8553-eaa7715aeaf2", + }, + }, + DeleteDatabaseResponse: &DeleteDatabaseResponse{ + Database: "my-db", + }, + Error: nil, + } +} + +func newMockOrganizationService() organizationService { + return &MockOrganizationService{ + ListOrganizationsResponse: &[]Organization{ + { + Name: "meow", + Slug: "meow", + }, + }, + Error: nil, + } +} + +func newMockDatabaseTokenService() databaseTokensService { + return &MockDatabaseTokensService{ + CreateDatabaseTokenResponse: &CreateDatabaseTokenResponse{ + JWT: "jwt-token", + }, + Error: nil, + } +} + +func (mg *MockGroupService) ListGroups(ctx context.Context) (*ListGroupResponse, error) { + return mg.ListGroupResponse, mg.Error +} + +func (mg *MockGroupService) CreateGroup(ctx context.Context, req CreateGroupRequest) (*CreateGroupResponse, error) { + return mg.CreateGroupResponse, mg.Error +} + +func (mg *MockGroupService) GetGroup(ctx context.Context, groupName string) (*GetGroupResponse, error) { + return mg.GetGroupResponse, mg.Error +} + +func (mg *MockGroupService) DeleteGroup(ctx context.Context, groupName string) (*DeleteGroupResponse, error) { + return mg.DeleteGroupResponse, mg.Error +} + +func (mg *MockGroupService) AddLocation(ctx context.Context, eq GroupLocationRequest) (*GroupLocationResponse, error) { + return mg.GroupLocationResponse, mg.Error +} + +func (mg *MockGroupService) RemoveLocation(ctx context.Context, req GroupLocationRequest) (*GroupLocationResponse, error) { + return mg.GroupLocationResponse, mg.Error +} + +func (md *MockDatabaseService) ListDatabases(ctx context.Context) (*ListDatabaseResponse, error) { + return md.ListDatabaseResponse, md.Error +} + +func (md *MockDatabaseService) CreateDatabase(ctx context.Context, req CreateDatabaseRequest) (*CreateDatabaseResponse, error) { + return md.CreateDatabaseResponse, md.Error +} + +func (md *MockDatabaseService) GetDatabase(ctx context.Context, dbName string) (*GetDatabaseResponse, error) { + return md.GetDatabaseResponse, md.Error +} + +func (md *MockDatabaseService) DeleteDatabase(ctx context.Context, dbName string) (*DeleteDatabaseResponse, error) { + return md.DeleteDatabaseResponse, md.Error +} + +func (mo *MockOrganizationService) ListOrganizations(ctx context.Context) (*[]Organization, error) { + return mo.ListOrganizationsResponse, mo.Error +} + +func (md *MockDatabaseTokensService) CreateDatabaseToken(ctx context.Context, req CreateDatabaseTokenRequest) (*CreateDatabaseTokenResponse, error) { + return md.CreateDatabaseTokenResponse, md.Error +}