From 01522e1feba32c84b5c49cb5a70e50e1918451b8 Mon Sep 17 00:00:00 2001 From: Arash Hatami Date: Mon, 19 Jun 2023 21:37:10 +0330 Subject: [PATCH] Revert "remove old package" This reverts commit 488d9247f4715372278b78997015afe3d7d73cd4. --- .github/CODEOWNERS | 1 + .github/dependabot.yml | 29 ++ .github/logo.svg | 1 + .github/workflows/codeql.yaml | 53 ++++ .gitignore | 50 ++++ .vscode/settings.json | 14 + CODE_OF_CONDUCT.md | 66 +++++ CONTRIBUTING.md | 15 + Dockerfile | 54 ++++ LICENSE | 21 ++ Makefile | 54 ++++ README.md | 97 +++++++ SECURITY.md | 26 ++ cmd/dns.go | 421 +++++++++++++++++++++++++++ cmd/main.go | 146 ++++++++++ go.mod | 28 ++ go.sum | 58 ++++ pkg/arvancloud.go | 255 +++++++++++++++++ pkg/arvancloud_test.go | 73 +++++ pkg/consts.go | 6 + pkg/dns.go | 134 +++++++++ pkg/dns_entity.go | 162 +++++++++++ pkg/dns_test.go | 523 ++++++++++++++++++++++++++++++++++ pkg/entity.go | 11 + pkg/errors.go | 156 ++++++++++ pkg/info.go | 18 ++ pkg/options.go | 71 +++++ pkg/options_test.go | 45 +++ pkg/utils.go | 26 ++ pkg/utils_test.go | 42 +++ 30 files changed, 2656 insertions(+) create mode 100644 .github/CODEOWNERS create mode 100644 .github/dependabot.yml create mode 100644 .github/logo.svg create mode 100644 .github/workflows/codeql.yaml create mode 100644 .gitignore create mode 100644 .vscode/settings.json create mode 100644 CODE_OF_CONDUCT.md create mode 100644 CONTRIBUTING.md create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 README.md create mode 100644 SECURITY.md create mode 100644 cmd/dns.go create mode 100644 cmd/main.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 pkg/arvancloud.go create mode 100644 pkg/arvancloud_test.go create mode 100644 pkg/consts.go create mode 100644 pkg/dns.go create mode 100644 pkg/dns_entity.go create mode 100644 pkg/dns_test.go create mode 100644 pkg/entity.go create mode 100644 pkg/errors.go create mode 100644 pkg/info.go create mode 100644 pkg/options.go create mode 100644 pkg/options_test.go create mode 100644 pkg/utils.go create mode 100644 pkg/utils_test.go diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..2a191f7 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @hatamiarash7 diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..3b92599 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,29 @@ +version: 2 + +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "daily" + assignees: + - "hatamiarash7" + reviewers: + - "hatamiarash7" + + - package-ecosystem: "docker" + directory: "/" + schedule: + interval: "daily" + assignees: + - "hatamiarash7" + reviewers: + - "hatamiarash7" + + - package-ecosystem: "gomod" + directory: "/" + schedule: + interval: "daily" + assignees: + - "hatamiarash7" + reviewers: + - "hatamiarash7" diff --git a/.github/logo.svg b/.github/logo.svg new file mode 100644 index 0000000..a0dd403 --- /dev/null +++ b/.github/logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/.github/workflows/codeql.yaml b/.github/workflows/codeql.yaml new file mode 100644 index 0000000..0e555db --- /dev/null +++ b/.github/workflows/codeql.yaml @@ -0,0 +1,53 @@ +name: CodeQL + +on: + push: + branches: + - main + paths: + - cmd + - pkg + - go.mod + - go.sum + - .github/workflows/codeql.yaml + pull_request: + branches: + - main + paths: + - cmd + - pkg + - go.mod + - go.sum + - .github/workflows/codeql.yaml + types: [opened, reopened, synchronize, ready_for_review] + workflow_dispatch: + +jobs: + analyze: + name: Analyze + if: github.event_name != 'pull_request' || !github.event.pull_request.draft + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + security-events: write + + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Initialize CodeQL + uses: github/codeql-action/init@v2 + with: + languages: "go" + + - name: Build + run: make cli + + - name: Test + run: go test -v -race ./... + + - name: CodeQL Analysis + uses: github/codeql-action/analyze@v2 + with: + category: "/language:go" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c15c17a --- /dev/null +++ b/.gitignore @@ -0,0 +1,50 @@ +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Binary folder +/bin + +# Test binary, built with `go test -c` +*.test +junit*.xml + +# Coverage files +coverage*[.html, .xml] +*.out + +# Lint +checkstyle-report.xml +yamllint-checkstyle.xml + +# Log file +*.log + +# IDEs +/.vscode +/.idea + +# Vim swap +[._]*.s[a-v][a-z] +[._]*.sw[a-p] +[._]s[a-rt-v][a-z] +[._]ss[a-gi-z] +[._]sw[a-p] + +# Vim session +Session.vim + +# Vim temporary +.netrwhist +*~ + +# Vim persistent undo +[._]*.un~ + +# vendor files +vendor/* + +# Others diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..66584e1 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,14 @@ +{ + "cSpell.words": [ + "ANAME", + "backoff", + "gcli", + "gookit", + "iodef", + "issuewild", + "Roadmap", + "stretchr", + "TLSA", + "unmarshalling" + ] +} diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..34d69e3 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,66 @@ +# Code of Conduct + +## Our Pledge + +In the interest of fostering an open and welcoming environment, we as +contributors and maintainers pledge to making participation in our project and +our community a harassment-free experience for everyone, regardless of age, body +size, disability, ethnicity, gender identity and expression, level of experience, +nationality, personal appearance, race, religion, or sexual identity and +orientation. + +## Standards + +Examples of behavior that contributes to creating a positive environment +include: + +- Using welcoming and inclusive language +- Being respectful of differing viewpoints and experiences +- Gracefully accepting constructive criticism +- Focusing on what is best for the community +- Showing empathy towards other community members + +Examples of unacceptable behavior by participants include: + +- The use of sexualized language or imagery and unwelcome sexual attention or + advances +- Trolling, insulting/derogatory comments, and personal or political attacks +- Public or private harassment +- Publishing others' private information, such as a physical or electronic + address, without explicit permission +- Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Responsibilities + +Project maintainers are responsible for clarifying the standards of acceptable +behavior and are expected to take appropriate and fair corrective action in +response to any instances of unacceptable behavior. + +Project maintainers have the right and responsibility to remove, edit, or +reject comments, commits, code, wiki edits, issues, and other contributions +that are not aligned to this Code of Conduct, or to ban temporarily or +permanently any contributor for other behaviors that they deem inappropriate, +threatening, offensive, or harmful. + +## Scope + +This Code of Conduct applies both within project spaces and in public spaces +when an individual is representing the project or its community. Examples of +representing a project or community include using an official project e-mail +address, posting via an official social media account, or acting as an appointed +representative at an online or offline event. Representation of a project may be +further defined and clarified by project maintainers. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported by contacting the project team at [cdn@arvancloud.ir](mailto:cdn@arvancloud.ir). All +complaints will be reviewed and investigated and will result in a response that +is deemed necessary and appropriate to the circumstances. The project team is +obligated to maintain confidentiality with regard to the reporter of an incident. +Further details of specific enforcement policies may be posted separately. + +Project maintainers who do not follow or enforce the Code of Conduct in good +faith may face temporary or permanent repercussions as determined by other +members of the project's leadership. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..1d37a80 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,15 @@ +# Contributing + +When contributing to this repository, please first discuss the change you wish to make via issue, +email, or any other method with the owners of this repository before making a change. + +Please note we have a [code of conduct](https://github.com/arvancloud/cdn-go/blob/main/CODE_OF_CONDUCT.md), please follow it in all your interactions with the project. + +## Pull Request Process + +1. Ensure any install or build dependencies are removed before the end of the layer when doing a + build. +2. Update the `README.md` with details of changes to the interface, this includes new environment + variables, exposed ports, useful file locations and container parameters. +3. The versioning scheme we use is [SemVer](https://semver.org/lang/fa/). +4. You should request a reviewer to merge it for you. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..91176f8 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,54 @@ +##################################### Build ##################################### + +FROM --platform=$BUILDPLATFORM golang:1.20-alpine as builder + +ARG APP_VERSION="undefined@docker" + +WORKDIR /src + +COPY cmd cmd +COPY pkg pkg +COPY go.* . + +ENV LDFLAGS="-s -w -X github.com/arvancloud/cdn-go/internal/pkg/version.version=$APP_VERSION" +ENV GO111MODULE=on +ARG TARGETOS TARGETARCH +ENV GOOS $TARGETOS +ENV GOARCH $TARGETARCH + +RUN set -x \ + && go version \ + && CGO_ENABLED=0 go build -trimpath -ldflags "$LDFLAGS" -o /src/cdn-uncompress cmd/*.go \ + && apk add -U --no-cache ca-certificates + +##################################### Compression ##################################### + +FROM hatamiarash7/upx:latest as upx + +COPY --from=builder /src / + +RUN upx --best --lzma -o /cdn /cdn-uncompress + +######################################## Final ######################################## + +FROM scratch + +ARG APP_VERSION="undefined@docker" +ARG DATE_CREATED + +LABEL \ + org.opencontainers.image.title="cdn" \ + org.opencontainers.image.description="Docker image for ArvanCloud CDN API " \ + org.opencontainers.image.url="https://github.com/arvancloud/cdn-go" \ + org.opencontainers.image.source="https://github.com/arvancloud/cdn-go" \ + org.opencontainers.image.vendor="arvancloud" \ + org.opencontainers.image.author="arvancloud" \ + org.opencontainers.version="$APP_VERSION" \ + org.opencontainers.image.created="$DATE_CREATED" \ + org.opencontainers.image.licenses="MIT" + +COPY --from=upx /cdn /cdn + +COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ + +ENTRYPOINT ["/cdn"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..63c4760 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 ArvanCloud + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..bc5617f --- /dev/null +++ b/Makefile @@ -0,0 +1,54 @@ +GOCMD=go +GOTEST=$(GOCMD) test +EXPORT_RESULT?=FALSE +SOURCES=$(shell find . -name '*.go' -not -name '*_test.go' -not -name "main.go") +BIN_DIR:=bin + +.PHONY: clean pre cli goconvey test coverage help +.DEFAULT_GOAL := help + +##################################### Binary ##################################### + +clean: ## Clean the bin directory + rm -rf $(BIN_DIR) + rm -f ./checkstyle-report.xml checkstyle-report.xml yamllint-checkstyle.xml + +pre: clean ## Create the bin directory + mkdir -p $(BIN_DIR) + +cli: pre $(BIN_DIR)/cdn ## Build the CLI binary + +$(BIN_DIR)/%: cmd $(SOURCES) + CGO_ENABLED=0 $(GOCMD) build -ldflags="-s -w" -o $@ $ coverage.xml + rm -f coverage.out +endif + +##################################### Help ##################################### + +help: ## Show this help + @grep -E '^[a-zA-Z_-]+:.*?## .*$$' Makefile | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}' diff --git a/README.md b/README.md new file mode 100644 index 0000000..93a5cdb --- /dev/null +++ b/README.md @@ -0,0 +1,97 @@ +# ArvanCloud CDN Go + +[![Release](https://github.com/arvancloud/cdn-go/actions/workflows/release.yaml/badge.svg)](https://github.com/arvancloud/cdn-go/actions/workflows/release.yaml) [![CodeQL](https://github.com/arvancloud/cdn-go/actions/workflows/codeql.yaml/badge.svg)](https://github.com/arvancloud/cdn-go/actions/workflows/codeql.yaml) ![Docker Image Size (latest semver)](https://img.shields.io/docker/image-size/r1cloud/cdn?sort=semver) ![GitHub go.mod Go version](https://img.shields.io/github/go-mod/go-version/arvancloud/cdn-go) ![GitHub release (latest SemVer)](https://img.shields.io/github/v/release/arvancloud/cdn-go?display_name=tag&label=version&sort=semver) + +![logo](.github/logo.svg) + +It's a Go library for interacting with the ArvanCloud CDN API. + +> **Note**: This project is under active development and may have problems and shortcomings. + +## Installation + +```bash +go get github.com/arvancloud/cdn-go +``` + +## Usage + +```go +package main + +import ( + "context" + "fmt" + "log" + "os" + + arvancloud "github.com/arvancloud/cdn-go/pkg" +) + +func main() { + api, err := arvancloud.New( + os.Getenv("ARVANCLOUD_API_KEY"), + arvancloud.Debug(false), + ) + if err != nil { + log.Fatal(err) + } + + ctx := context.Background() + + resource := arvancloud.Resource{ + Domain: "test.ir", + } + + record := arvancloud.CreateDNSRecordParams{ + Type: "A", + Name: "@", + Value: []arvancloud.DNSRecord_Value_A{ + { + IP: "1.1.1.1", + }, + }, + TTL: 120, + UpstreamHTTPS: "https", + IPFilterMode: arvancloud.DNSRecord_IPFilterMode{ + Count: "single", + Order: "none", + GeoFilter: "none", + }, + } + + u, err := api.CreateDNSRecord(ctx, resource, record) + if err != nil { + log.Fatal(err.Error()) + } + + fmt.Printf("%+v", u) +} +``` + +## Roadmap + +- [ ] Products + - [x] DNS + - [ ] Firewall + - [ ] WAF + - [ ] DDoS + - [ ] Cache + - [ ] Load Balancer + - [ ] Page Rule + - [ ] Acceleration + - [ ] Custom Pages + - [ ] Redirect + - [ ] Log Forwarder + - [ ] L4 Proxy + - [ ] Rate Limit +- [x] Package + - [x] CLI + - [x] Official Release + - [x] CI/CD + +## Contributing + +We welcome contributions from the community. Please consider that this project is under active development as we expand it to cover the CDN API. + +Please report any issues you find in the [Issues page](https://github.com/arvancloud/cdn-go/issues) or send us an email at [cdn@arvancloud.ir](mailto:cdn@arvancloud.ir). diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..e03d194 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,26 @@ +# Security Policy + +## Reporting Potential Security Issues + +If you have encountered a potential security vulnerability in this project, please report it to us at [cdn@arvancloud.ir](mailto:cdn@arvancloud.ir). We will work with you to verify the vulnerability and patch it. + +When reporting issues, please provide the following information: + +- Component(s) affected +- A description indicating how to reproduce the issue +- A summary of the security vulnerability and impact + +We request that you contact us via the email address above and give the project contributors a chance to resolve the vulnerability and issue a new release prior to any public exposure; this helps protect the project's users, and provides them with a chance to upgrade and/or update in order to protect their applications. + +## Policy + +If we verify a reported security vulnerability, our policy is: + +- We will patch the current release branch, as well as the immediate prior minor release branch. +- After patching the release branches, we will immediately issue new security fix releases for each patched release branch. + +## Supported Versions + +| Version | Supported | +| ------- | ------------------ | +| 0.1.x | :white_check_mark: | diff --git a/cmd/dns.go b/cmd/dns.go new file mode 100644 index 0000000..18edfdc --- /dev/null +++ b/cmd/dns.go @@ -0,0 +1,421 @@ +package main + +import ( + "context" + "log" + "strconv" + + arvancloud "github.com/arvancloud/cdn-go/pkg" + "github.com/gookit/gcli/v3/interact" +) + +// List of functions for every DNS record type +var funcs = map[string]func() interface{}{ + "A": createDNSRecord_A, + "AAAA": createDNSRecord_AAAA, + "MX": createDNSRecord_MX, + "NS": createDNSRecord_NS, + "SRV": createDNSRecord_SRV, + "TXT": createDNSRecord_TXT, + "SPF": createDNSRecord_SPF, + "DKIM": createDNSRecord_DKIM, + "ANAME": createDNSRecord_ANAME, + "CNAME": createDNSRecord_CNAME, + "PTR": createDNSRecord_PTR, + "TLSA": createDNSRecord_TLSA, + "CAA": createDNSRecord_CAA, +} + +// GetDNSRecord will return a single DNS record +func GetDNSRecord(ctx context.Context, api *arvancloud.API, domain string, id string) { + resource := arvancloud.Resource{ + Domain: domain, + } + + u, err := api.GetDNSRecord(ctx, resource, id) + if err != nil { + log.Fatal(err.Error()) + } + + arvancloud.PrettyPrint(u) +} + +// ListDNSRecords will list a part of DNS records +func ListDNSRecords(ctx context.Context, api *arvancloud.API, domain string, page int, perPage int) { + param := arvancloud.ListDNSRecordsParams{ + Type: "a", + Page: page, + PerPage: perPage, + } + + resource := arvancloud.Resource{ + Domain: domain, + } + + u, err := api.ListDNSRecords(ctx, resource, param) + if err != nil { + log.Fatal(err.Error()) + } + + arvancloud.PrettyPrint(u) +} + +// DeleteDNSRecord will delete a DNS record +func DeleteDNSRecord(ctx context.Context, api *arvancloud.API, domain string, id string) { + resource := arvancloud.Resource{ + Domain: domain, + } + + u, err := api.DeleteDNSRecord(ctx, resource, id) + if err != nil { + log.Fatal(err.Error()) + } + + arvancloud.PrettyPrint(u) +} + +// CreateDNSRecord will create a DNS record +func CreateDNSRecord(ctx context.Context, api *arvancloud.API, domain string, record *arvancloud.CreateDNSRecordParams) { + resource := arvancloud.Resource{ + Domain: domain, + } + + u, err := api.CreateDNSRecord(ctx, resource, *record) + if err != nil { + log.Fatal(err.Error()) + } + + arvancloud.PrettyPrint(u) +} + +// createDNSRecordParams will configure the parameters for creating a DNS record +func createDNSRecordParams(params *arvancloud.CreateDNSRecordParams) { + recordName, _ := interact.ReadInput("Record name: ") + params.Name = recordName + + recordType := interact.SelectOne( + "Record type", + []string{ + "A", + "AAAA", + "MX", + "NS", + "SRV", + "TXT", + "SPF", + "DKIM", + "ANAME", + "CNAME", + "PTR", + "TLSA", + "CAA", + }, + "", + false, + ) + params.Type = recordType + + ttl, _ := interact.ReadInput("Record TTL: ") + recordTTL, err := strconv.Atoi(ttl) + if err != nil { + log.Fatal(err.Error()) + } + params.TTL = recordTTL + + cloud := interact.SelectOne( + "Record Cloud:", + []string{ + "True", + "False", + }, + "", + false, + ) + recordCloud, err := strconv.ParseBool(cloud) + if err != nil { + log.Fatal(err.Error()) + } + params.Cloud = recordCloud + + recordUpstreamHTTPS := interact.SelectOne( + "Record Upstream HTTPS:", + []string{ + "default", + "auto", + "http", + "https", + }, + "", + false, + ) + params.UpstreamHTTPS = recordUpstreamHTTPS + + // Call function based on record type + if f, ok := funcs[recordType]; ok { + params.Value = f() + } else { + log.Fatal("Unknown record type:", recordType) + } + + params.IPFilterMode = createDNSRecord_IPFilterMode() +} + +func createDNSRecord_A() interface{} { + ip, _ := interact.ReadInput("Record IP: ") + + p, _ := interact.ReadInput("Record Port: ") + port, err := strconv.Atoi(p) + if err != nil { + log.Fatal(err.Error()) + } + + w, _ := interact.ReadInput("Record Weight: ") + weight, err := strconv.Atoi(w) + if err != nil { + log.Fatal(err.Error()) + } + + country, _ := interact.ReadInput("Record Country: ") + + return arvancloud.DNSRecord_Value_A{ + IP: ip, + Port: port, + Weight: weight, + Country: country, + } +} + +func createDNSRecord_AAAA() interface{} { + ip, _ := interact.ReadInput("Record IP: ") + + p, _ := interact.ReadInput("Record Port: ") + port, err := strconv.Atoi(p) + if err != nil { + log.Fatal(err.Error()) + } + + w, _ := interact.ReadInput("Record Weight: ") + weight, err := strconv.Atoi(w) + if err != nil { + log.Fatal(err.Error()) + } + + country, _ := interact.ReadInput("Record Country: ") + + return arvancloud.DNSRecord_Value_AAAA{ + IP: ip, + Port: port, + Weight: weight, + Country: country, + } +} + +func createDNSRecord_MX() interface{} { + host, _ := interact.ReadInput("Record Host: ") + + p, _ := interact.ReadInput("Record Priority: ") + priority, err := strconv.Atoi(p) + if err != nil { + log.Fatal(err.Error()) + } + + return arvancloud.DNSRecord_Value_MX{ + Host: host, + Priority: priority, + } +} + +func createDNSRecord_NS() interface{} { + host, _ := interact.ReadInput("Record Host: ") + + return arvancloud.DNSRecord_Value_NS{ + Host: host, + } +} + +func createDNSRecord_SRV() interface{} { + target, _ := interact.ReadInput("Record Target: ") + + p, _ := interact.ReadInput("Record Port: ") + port, err := strconv.Atoi(p) + if err != nil { + log.Fatal(err.Error()) + } + + w, _ := interact.ReadInput("Record Weight: ") + weight, err := strconv.Atoi(w) + if err != nil { + log.Fatal(err.Error()) + } + + p, _ = interact.ReadInput("Record Priority: ") + priority, err := strconv.Atoi(p) + if err != nil { + log.Fatal(err.Error()) + } + + return arvancloud.DNSRecord_Value_SRV{ + Target: target, + Port: port, + Weight: weight, + Priority: priority, + } +} + +func createDNSRecord_TXT() interface{} { + text, _ := interact.ReadInput("Record Text: ") + + return arvancloud.DNSRecord_Value_TXT{ + Text: text, + } +} + +func createDNSRecord_SPF() interface{} { + text, _ := interact.ReadInput("Record Text: ") + + return arvancloud.DNSRecord_Value_SPF{ + Text: text, + } +} + +func createDNSRecord_DKIM() interface{} { + text, _ := interact.ReadInput("Record Text: ") + + return arvancloud.DNSRecord_Value_DKIM{ + Text: text, + } +} + +func createDNSRecord_ANAME() interface{} { + location, _ := interact.ReadInput("Record Location: ") + + host_header := interact.SelectOne( + "Record Host header:", + []string{ + "source", + "dest", + }, + "", + false, + ) + + p, _ := interact.ReadInput("Record Port: ") + port, err := strconv.Atoi(p) + if err != nil { + log.Fatal(err.Error()) + } + + return arvancloud.DNSRecord_Value_ANAME{ + Location: location, + HostHeader: host_header, + Port: port, + } +} + +func createDNSRecord_CNAME() interface{} { + host, _ := interact.ReadInput("Record Host: ") + + host_header := interact.SelectOne( + "Record Host header:", + []string{ + "source", + "dest", + }, + "", + false, + ) + + p, _ := interact.ReadInput("Record Port: ") + port, err := strconv.Atoi(p) + if err != nil { + log.Fatal(err.Error()) + } + + return arvancloud.DNSRecord_Value_CNAME{ + Host: host, + HostHeader: host_header, + Port: port, + } +} + +func createDNSRecord_PTR() interface{} { + domain, _ := interact.ReadInput("Record Domain: ") + + return arvancloud.DNSRecord_Value_PTR{ + Domain: domain, + } +} + +func createDNSRecord_TLSA() interface{} { + usage, _ := interact.ReadInput("Record Usage: ") + selector, _ := interact.ReadInput("Record Selector: ") + matching_type, _ := interact.ReadInput("Record Matching type: ") + certificate, _ := interact.ReadInput("Record Certificate: ") + + return arvancloud.DNSRecord_Value_TLSA{ + Usage: usage, + Selector: selector, + MatchingType: matching_type, + Certificate: certificate, + } +} + +func createDNSRecord_CAA() interface{} { + value, _ := interact.ReadInput("Record Value: ") + + tag := interact.SelectOne( + "Record Tag:", + []string{ + "issuewild", + "issue", + "iodef", + }, + "", + false, + ) + + return arvancloud.DNSRecord_Value_CAA{ + Value: value, + Tag: tag, + } +} + +func createDNSRecord_IPFilterMode() interface{} { + count := interact.SelectOne( + "Record Count:", + []string{ + "single", + "multi", + }, + "", + false, + ) + + order := interact.SelectOne( + "Record Order:", + []string{ + "none", + "weighted", + "rr", + }, + "", + false, + ) + + geo_filter := interact.SelectOne( + "Record Geo filter:", + []string{ + "none", + "location", + "country", + }, + "", + false, + ) + + return arvancloud.DNSRecord_IPFilterMode{ + Count: count, + Order: order, + GeoFilter: geo_filter, + } +} diff --git a/cmd/main.go b/cmd/main.go new file mode 100644 index 0000000..3b4a263 --- /dev/null +++ b/cmd/main.go @@ -0,0 +1,146 @@ +package main + +import ( + "context" + "log" + "os" + + arvancloud "github.com/arvancloud/cdn-go/pkg" + "github.com/gookit/gcli/v3" +) + +var ( + ctx = context.Background() +) + +func main() { + // Create a new instance of the API client + api, err := arvancloud.New( + os.Getenv("ARVANCLOUD_API_KEY"), + arvancloud.Debug(false), + ) + if err != nil { + log.Fatal(err) + } + + // Create a new instance of the CLI application + app := gcli.NewApp() + app.Version = arvancloud.VERSION + app.Desc = "ArvanCloud CDN" + + // Configure CLI arguments + configureArgs(app, api) + + app.Run(nil) +} + +func configureArgs(app *gcli.App, api *arvancloud.API) { + domainArg := &gcli.Argument{ + Name: "domain", + Desc: "Your domain, is required", + Required: true, + } + + // Create DNS record + + createDNSRecord := &gcli.Command{ + Name: "create", + Desc: "Create a single DNS record", + Aliases: []string{"c"}, + Func: func(cmd *gcli.Command, _ []string) error { + var params *arvancloud.CreateDNSRecordParams + params = new(arvancloud.CreateDNSRecordParams) + + createDNSRecordParams(params) + + CreateDNSRecord( + ctx, + api, + cmd.Arg("domain").String(), + params, + ) + + return nil + }, + Config: func(cmd *gcli.Command) { + cmd.AddArgument(domainArg) + }, + } + + // Get DNS record + + getDNSRecord := &gcli.Command{ + Name: "get", + Desc: "Get a single DNS record", + Aliases: []string{"g"}, + Examples: `{$fullCmd} domain.ir 4j6eff36-8e7b-4c12-a7d1-20804e839a67`, + Func: func(cmd *gcli.Command, _ []string) error { + GetDNSRecord( + ctx, + api, + cmd.Arg("domain").String(), + cmd.Arg("id").String(), + ) + return nil + }, + Config: func(cmd *gcli.Command) { + cmd.AddArgument(domainArg) + cmd.AddArg("id", "The id of DNS record, is required", true) + }, + } + + // List DNS records + + listDNSRecord := &gcli.Command{ + Name: "list", + Desc: "List DNS records", + Aliases: []string{"l", "ls"}, + Examples: `{$fullCmd} domain.ir +{$fullCmd} domain.ir 2 +{$fullCmd} domain.ir 1 2`, + Func: func(cmd *gcli.Command, _ []string) error { + ListDNSRecords( + ctx, + api, + cmd.Arg("domain").String(), + cmd.Arg("page").Int(), + cmd.Arg("per-page").Int(), + ) + return nil + }, + Config: func(cmd *gcli.Command) { + cmd.AddArgument(domainArg) + cmd.AddArg("page", "The page number for pagination", false) + cmd.AddArg("per-page", "Number of records in each page", false) + }, + } + + // Delete DNS record + + deleteDNSRecord := &gcli.Command{ + Name: "delete", + Desc: "Delete a single DNS record", + Aliases: []string{"d", "del"}, + Examples: `{$fullCmd} domain.ir 4j6eff36-8e7b-4c12-a7d1-20804e839a67`, + Func: func(cmd *gcli.Command, _ []string) error { + DeleteDNSRecord( + ctx, + api, + cmd.Arg("domain").String(), + cmd.Arg("id").String(), + ) + return nil + }, + Config: func(cmd *gcli.Command) { + cmd.AddArgument(domainArg) + cmd.AddArg("id", "The id of DNS record, is required", true) + }, + } + + app.Add( + createDNSRecord, + getDNSRecord, + listDNSRecord, + deleteDNSRecord, + ) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..6a1634c --- /dev/null +++ b/go.mod @@ -0,0 +1,28 @@ +module github.com/arvancloud/cdn-go + +go 1.20 + +require ( + github.com/google/go-querystring v1.1.0 + github.com/gookit/gcli/v3 v3.2.1 + github.com/stretchr/testify v1.8.4 + golang.org/x/time v0.3.0 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/google/go-cmp v0.5.9 // indirect + github.com/gookit/color v1.5.2 // indirect + github.com/gookit/goutil v0.6.6 // indirect + github.com/kr/pretty v0.3.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/rogpeppe/go-internal v1.9.0 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/crypto v0.6.0 // indirect + golang.org/x/sync v0.1.0 // indirect + golang.org/x/sys v0.5.0 // indirect + golang.org/x/term v0.5.0 // indirect + golang.org/x/text v0.7.0 // indirect + gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..a9378c0 --- /dev/null +++ b/go.sum @@ -0,0 +1,58 @@ +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +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/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= +github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= +github.com/gookit/color v1.5.2 h1:uLnfXcaFjlrDnQDT+NCBcfhrXqYTx/rcCa6xn01Y8yI= +github.com/gookit/color v1.5.2/go.mod h1:w8h4bGiHeeBpvQVePTutdbERIUf3oJE5lZ8HM0UgXyg= +github.com/gookit/gcli/v3 v3.2.1 h1:xiCJk8xOl3vB04mOIzn3aSAMttCtQpuLwaznsV9cfb4= +github.com/gookit/gcli/v3 v3.2.1/go.mod h1:hfMxHR72+JNFN5nMOZPau2e4G1z57pMfIPq+dZDaYkI= +github.com/gookit/goutil v0.6.6 h1:XdvnPocHpKDXA+eykfc/F846Y1V2Vyo3+cV8rfliG90= +github.com/gookit/goutil v0.6.6/go.mod h1:D++7kbQd/6vECyYTxB5tq6AKDIG9ZYwZNhubWJvN9dw= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +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/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= +github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778/go.mod h1:2MuV+tbUrU1zIOPMxZ5EncGwgmMJsa+9ucAQZXxsObs= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +golang.org/x/crypto v0.6.0 h1:qfktjS5LUO+fFKeJXZ+ikTRijMmljikvG68fpMMruSc= +golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= +golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.5.0 h1:MUK/U/4lj1t1oPg0HfuXDN/Z1wv31ZJ/YcPiGccS4DU= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.5.0 h1:n2a8QNdAb0sZNpU9R1ALUXBbY+w51fCQDN+7EdxNBsY= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/text v0.7.0 h1:4BRB4x83lYWy72KwLD/qYDuTu7q9PjSagHvijDw7cLo= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4= +golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +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/pkg/arvancloud.go b/pkg/arvancloud.go new file mode 100644 index 0000000..13b1ae5 --- /dev/null +++ b/pkg/arvancloud.go @@ -0,0 +1,255 @@ +package arvancloud + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log" + "math" + "net/http" + "net/http/httputil" + "regexp" + "strings" + "time" + + "golang.org/x/time/rate" +) + +type API struct { + APIKey string + APIEmail string + BaseURL string + UserAgent string + headers http.Header + httpClient *http.Client + rateLimiter *rate.Limiter + retryPolicy RetryPolicy + logger Logger + Debug bool +} + +type RetryPolicy struct { + MaxRetries int + MinRetryDelay time.Duration + MaxRetryDelay time.Duration +} + +type Logger interface { + Printf(format string, v ...interface{}) +} + +// New creates a new instance of the API client with the given API token and options. +func New(token string, opts ...Option) (*API, error) { + if token == "" { + return nil, errors.New(errEmptyAPIToken) + } + + api, err := newClient(opts...) + if err != nil { + return nil, err + } + + api.APIKey = token + + return api, nil +} + +func newClient(opts ...Option) (*API, error) { + // Create a logger that discards all log output + silentLogger := log.New(io.Discard, "", log.LstdFlags) + + // Create a new API object with default values + api := &API{ + BaseURL: fmt.Sprintf("%s://%s%s", SCHEME, HOST_NAME, BASE_PATH), + UserAgent: UA + "/" + VERSION, + headers: make(http.Header), + rateLimiter: rate.NewLimiter(rate.Limit(4), 1), + retryPolicy: RetryPolicy{ + MaxRetries: 3, + MinRetryDelay: 1 * time.Second, + MaxRetryDelay: 30 * time.Second, + }, + logger: silentLogger, + } + + // Parse the options and apply them to the API object + err := api.parseOptions(opts...) + if err != nil { + return nil, fmt.Errorf("options parsing failed: %w", err) + } + + // Create a new HTTP client using the default client + api.httpClient = http.DefaultClient + + return api, nil +} + +func (api *API) makeRequest(ctx context.Context, method, uri string, record interface{}) ([]byte, error) { + var err error + var resp *http.Response + var respErr error + var respBody []byte + + for i := 0; i <= api.retryPolicy.MaxRetries; i++ { + var reqBody io.Reader + + if record != nil { + if r, ok := record.(io.Reader); ok { + reqBody = r + } else if paramBytes, ok := record.([]byte); ok { + reqBody = bytes.NewReader(paramBytes) + } else { + var jsonBody []byte + jsonBody, err = json.Marshal(record) + + if err != nil { + return nil, fmt.Errorf("error marshalling record data to JSON: %w", err) + } + + reqBody = bytes.NewReader(jsonBody) + } + } + + if i > 0 { + sleepDuration := time.Duration(math.Pow(2, float64(i-1)) * float64(api.retryPolicy.MinRetryDelay)) + + if sleepDuration > api.retryPolicy.MaxRetryDelay { + sleepDuration = api.retryPolicy.MaxRetryDelay + } + + api.logger.Printf("Sleeping %s before retry attempt number %d for request %s %s", sleepDuration.String(), i, method, uri) + + select { + case <-time.After(sleepDuration): + case <-ctx.Done(): + return nil, fmt.Errorf("operation aborted during backoff: %w", ctx.Err()) + } + } + + err = api.rateLimiter.Wait(ctx) + if err != nil { + return nil, fmt.Errorf("error caused by request rate limiting: %w", err) + } + + resp, respErr = api.request(ctx, method, uri, reqBody) + + // short circuit processing on context timeouts + if respErr != nil && errors.Is(respErr, context.DeadlineExceeded) { + return nil, respErr + } + + // retry if the server is rate limiting us or if it failed + // assumes server operations are rolled back on failure + if respErr != nil || resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= 500 { + if resp != nil && resp.StatusCode == http.StatusTooManyRequests { + respErr = errors.New("exceeded available rate limit retries") + } + + if respErr == nil { + respErr = fmt.Errorf("received %s response (HTTP %d), please try again later", strings.ToLower(http.StatusText(resp.StatusCode)), resp.StatusCode) + } + continue + } else { + respBody, err = io.ReadAll(resp.Body) + defer resp.Body.Close() + if err != nil { + return nil, fmt.Errorf("could not read response body: %w", err) + } + + break + } + } + + // still had an error after all retries + if respErr != nil { + return nil, respErr + } + + if resp.StatusCode >= http.StatusBadRequest { + if resp.StatusCode >= http.StatusInternalServerError { + + return nil, &ServiceError{arvancloudError: &Error{ + Code: resp.StatusCode, + Message: errInternalServiceError, + }} + } + + errBody := &CreateDNSRecord_Response{} + err = json.Unmarshal(respBody, &errBody) + if err != nil { + + return nil, fmt.Errorf(errUnmarshalErrorBody+": %w", err) + } + + err := &Error{ + Code: resp.StatusCode, + Message: errBody.Message, + Errors: errBody.Errors, + Type: ErrorTypeRequest, + } + + return nil, &RequestError{arvancloudError: err} + } + + return respBody, nil +} + +func (api *API) request(ctx context.Context, method, uri string, reqBody io.Reader) (*http.Response, error) { + req, err := http.NewRequestWithContext(ctx, method, api.BaseURL+uri, reqBody) + if err != nil { + return nil, fmt.Errorf("HTTP request creation failed: %w", err) + } + + combinedHeaders := make(http.Header) + copyHeader(combinedHeaders, api.headers) + req.Header = combinedHeaders + + req.Header.Set("Authorization", api.APIKey) + req.Header.Set("User-Agent", api.UserAgent) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + + if api.Debug { + dump, err := httputil.DumpRequestOut(req, true) + if err != nil { + return nil, err + } + + // Strip out sensitive information + sensitiveKeys := []string{api.APIKey} + for _, key := range sensitiveKeys { + if key != "" { + valueRegex := regexp.MustCompile(fmt.Sprintf("(?m)%s", key)) + dump = valueRegex.ReplaceAll(dump, []byte("[redacted]")) + } + } + log.Printf("\n%s", string(dump)) + } + + resp, err := api.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("HTTP request failed: %w", err) + } + + if api.Debug { + dump, err := httputil.DumpResponse(resp, true) + if err != nil { + return resp, err + } + log.Printf("\n%s", string(dump)) + } + + return resp, nil +} + +// copyHeader copies all header fields and their values from the source header to the target header. +func copyHeader(target, source http.Header) { + // Iterate over all header fields in the source header. + for k, vs := range source { + // Copy the header field and its values to the target header. + target[k] = vs + } +} diff --git a/pkg/arvancloud_test.go b/pkg/arvancloud_test.go new file mode 100644 index 0000000..a0d0121 --- /dev/null +++ b/pkg/arvancloud_test.go @@ -0,0 +1,73 @@ +package arvancloud + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" +) + +var ( + // HTTP request multiplexer + mux *http.ServeMux + + // API client for test + client *API + + // HTTP server used for mocking + server *httptest.Server +) + +func setup(opts ...Option) { + // Create a test server + mux = http.NewServeMux() + server = httptest.NewServer(mux) + + // Disable rate limits and retries for test purpose + opts = append([]Option{UsingRateLimit(100000), UsingRetryPolicy(0, 0, 0)}, opts...) + + // Configure client + client, _ = New("deadbeef", opts...) + client.BaseURL = server.URL +} + +func teardown() { + server.Close() +} + +func TestHeaders(t *testing.T) { + // Default + setup() + mux.HandleFunc("/domains/"+testDomain+"/dns-records", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method, "Expected method 'GET', got %s", r.Method) + assert.Equal(t, "application/json", r.Header.Get("Content-Type")) + }) + teardown() + + // Override defaults + headers := make(http.Header) + headers.Set("Content-Type", "application/graphql") + headers.Add("H1", "V1") + headers.Add("H2", "V2") + setup(Headers(headers)) + mux.HandleFunc("/domains/"+testDomain+"/dns-records", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method, "Expected method 'GET', got %s", r.Method) + assert.Equal(t, "application/graphql", r.Header.Get("Content-Type")) + assert.Equal(t, "V1", r.Header.Get("H1")) + assert.Equal(t, "V2", r.Header.Get("H2")) + }) + teardown() + + // Authentication + setup() + client, err := New("TEST_TOKEN") + assert.NoError(t, err) + client.BaseURL = server.URL + mux.HandleFunc("/domains/"+testDomain+"/dns-records", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method, "Expected method 'GET', got %s", r.Method) + assert.Equal(t, "Bearer TEST_TOKEN", r.Header.Get("Authorization")) + assert.Equal(t, "application/json", r.Header.Get("Content-Type")) + }) + teardown() +} diff --git a/pkg/consts.go b/pkg/consts.go new file mode 100644 index 0000000..85fc94b --- /dev/null +++ b/pkg/consts.go @@ -0,0 +1,6 @@ +package arvancloud + +const ( + // Testing + testDomain = "test.ir" +) diff --git a/pkg/dns.go b/pkg/dns.go new file mode 100644 index 0000000..f4bb945 --- /dev/null +++ b/pkg/dns.go @@ -0,0 +1,134 @@ +package arvancloud + +import ( + "context" + "encoding/json" + "fmt" + "net/http" +) + +// CreateDNSRecord will create a DNS record +// ? Documentation: https://www.arvancloud.ir/api/cdn/4.0#tag/DNS-Management/operation/dns_records.store +func (api *API) CreateDNSRecord(ctx context.Context, resource Resource, record CreateDNSRecordParams) (res *CreateDNSRecord_Response, err error) { + if resource.Domain == "" { + return nil, ErrMissingDomain + } + + uri := fmt.Sprintf("/domains/%s/dns-records", resource.Domain) + response, err := api.makeRequest(ctx, http.MethodPost, uri, record) + if err != nil { + return nil, err + } + + res = &CreateDNSRecord_Response{} + err = json.Unmarshal(response, &res) + if err != nil { + return nil, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return res, nil +} + +// GetDNSRecord will return a single DNS record +// ? Documentation: https://www.arvancloud.ir/api/cdn/4.0#tag/DNS-Management/operation/dns_records.show +func (api *API) GetDNSRecord(ctx context.Context, resource Resource, recordID string) (*DNSRecord, error) { + if resource.Domain == "" { + return nil, ErrMissingDomain + } + + if recordID == "" { + return nil, ErrMissingDNSRecordID + } + + uri := fmt.Sprintf("/domains/%s/dns-records/%s", resource.Domain, recordID) + response, err := api.makeRequest(ctx, http.MethodGet, uri, nil) + if err != nil { + return nil, err + } + + res := &DNSRecord_Response{} + err = json.Unmarshal(response, &res) + if err != nil { + return nil, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return &res.Data, nil +} + +// ListDNSRecords will list a part of DNS records +// ? Documentation: https://www.arvancloud.ir/api/cdn/4.0#tag/DNS-Management/operation/dns_records.list +func (api *API) ListDNSRecords(ctx context.Context, resource Resource, params ListDNSRecordsParams) ([]DNSRecord, error) { + if resource.Domain == "" { + return nil, ErrMissingDomain + } + + if params.Page < 1 { + params.Page = 1 + } + + uri := buildURI(fmt.Sprintf("/domains/%s/dns-records", resource.Domain), params) + res, err := api.makeRequest(ctx, http.MethodGet, uri, nil) + if err != nil { + return nil, err + } + + var listResponse ListDNSRecord_Response + err = json.Unmarshal(res, &listResponse) + if err != nil { + return nil, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return listResponse.Data, nil +} + +// UpdateDNSRecord will update a DNS record +// ? Documentation: https://www.arvancloud.ir/api/cdn/4.0#tag/DNS-Management/operation/dns_records.update +func (api *API) UpdateDNSRecord(ctx context.Context, resource Resource, recordID string, params UpdateDNSRecordParams) (*UpdateDNSRecord_Response, error) { + if resource.Domain == "" { + return nil, ErrMissingDomain + } + + if recordID == "" { + return nil, ErrMissingDNSRecordID + } + + uri := fmt.Sprintf("/domains/%s/dns-records/%s", resource.Domain, recordID) + response, err := api.makeRequest(ctx, http.MethodPut, uri, params) + if err != nil { + return nil, err + } + + res := &UpdateDNSRecord_Response{} + err = json.Unmarshal(response, &res) + if err != nil { + return nil, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return res, nil +} + +// DeleteDNSRecord will delete a DNS record +// ? Documentation: https://www.arvancloud.ir/api/cdn/4.0#tag/DNS-Management/operation/dns_records.remove +func (api *API) DeleteDNSRecord(ctx context.Context, resource Resource, recordID string) (*DeleteDNSRecord_Response, error) { + if resource.Domain == "" { + return nil, ErrMissingDomain + } + + if recordID == "" { + return nil, ErrMissingDNSRecordID + } + + uri := fmt.Sprintf("/domains/%s/dns-records/%s", resource.Domain, recordID) + response, err := api.makeRequest(ctx, http.MethodDelete, uri, nil) + if err != nil { + return nil, err + } + + res := &DeleteDNSRecord_Response{} + err = json.Unmarshal(response, &res) + if err != nil { + return nil, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return res, nil +} diff --git a/pkg/dns_entity.go b/pkg/dns_entity.go new file mode 100644 index 0000000..6b1ab8e --- /dev/null +++ b/pkg/dns_entity.go @@ -0,0 +1,162 @@ +package arvancloud + +// DSNRecord is a DSN record structure for a domain +type DNSRecord struct { + ID string `json:"id,omitempty"` + Type string `json:"type,omitempty"` + Name string `json:"name,omitempty"` + Value interface{} `json:"value,omitempty"` + TTL int `json:"ttl,omitempty"` + Cloud bool `json:"cloud,omitempty"` + UpstreamHTTPS string `json:"upstream_https,omitempty"` + IPFilterMode interface{} `json:"ip_filter_mode,omitempty"` + IsProtected bool `json:"is_protected,omitempty"` + CreatedAt string `json:"created_at,omitempty"` + UpdatedAt string `json:"updated_at,omitempty"` +} + +// DNSRecord_Response is response structure contains +// DSN record's data +type DNSRecord_Response struct { + Data DNSRecord `json:"data,omitempty"` +} + +// ----------------------------------------------------------- DSN Records + +// DNSRecord_Value_A is a structure for A record +type DNSRecord_Value_A struct { + IP string `json:"ip,omitempty"` + Port int `json:"port,omitempty"` + Weight int `json:"weight,omitempty"` + Country string `json:"country,omitempty"` +} + +// DNSRecord_Value_AAAA is a structure for AAAA record +type DNSRecord_Value_AAAA = DNSRecord_Value_A + +// DNSRecord_Value_MX is a structure for MX record +type DNSRecord_Value_MX struct { + Host string `json:"host,omitempty"` + Priority int `json:"priority,omitempty"` +} + +// DNSRecord_Value_NS is a structure for NS record +type DNSRecord_Value_NS struct { + Host string `json:"host,omitempty"` +} + +// DNSRecord_Value_SRV is a structure for SRV record +type DNSRecord_Value_SRV struct { + Target string `json:"target,omitempty"` + Port int `json:"port,omitempty"` + Weight int `json:"weight,omitempty"` + Priority int `json:"priority,omitempty"` +} + +// DNSRecord_Value_TXT is a structure for TXT record +type DNSRecord_Value_TXT struct { + Text string `json:"text,omitempty"` +} + +// DNSRecord_Value_SPF is a structure for SPF record +type DNSRecord_Value_SPF = DNSRecord_Value_TXT + +// DNSRecord_Value_DKIM is a structure for DKIM record +type DNSRecord_Value_DKIM = DNSRecord_Value_TXT + +// DNSRecord_Value_ANAME is a structure for ANAME record +type DNSRecord_Value_ANAME struct { + Location string `json:"location,omitempty"` + HostHeader string `json:"host_header,omitempty"` + Port int `json:"port,omitempty"` +} + +// DNSRecord_Value_CNAME is a structure for CNAME record +type DNSRecord_Value_CNAME struct { + Host string `json:"host,omitempty"` + HostHeader string `json:"host_header,omitempty"` + Port int `json:"port,omitempty"` +} + +// DNSRecord_Value_PTR is a structure for PTR record +type DNSRecord_Value_PTR struct { + Domain string `json:"domain,omitempty"` +} + +// DNSRecord_Value_TLSA is a structure for TLSA record +type DNSRecord_Value_TLSA struct { + Usage string `json:"usage,omitempty"` + Selector string `json:"selector,omitempty"` + MatchingType string `json:"matching_type,omitempty"` + Certificate string `json:"certificate,omitempty"` +} + +// DNSRecord_Value_CAA is a structure for CAA record +type DNSRecord_Value_CAA struct { + Value string `json:"value,omitempty"` + Tag string `json:"tag,omitempty"` +} + +// ----------------------------------------------------------- DSN Operations - Create + +// CreateDNSRecordParams is a structure for all needed parameters +// to create DNS record +type CreateDNSRecordParams struct { + Type string `json:"type,omitempty"` + Name string `json:"name,omitempty"` + Value interface{} `json:"value,omitempty"` + TTL int `json:"ttl,omitempty"` + Cloud bool `json:"cloud,omitempty"` + UpstreamHTTPS string `json:"upstream_https,omitempty"` + IPFilterMode interface{} `json:"ip_filter_mode,omitempty"` +} + +// DNSRecord_IPFilterMode is a structure for IP Filter Mode when +// creating a DNS record +type DNSRecord_IPFilterMode struct { + Count string `json:"count,omitempty"` + Order string `json:"order,omitempty"` + GeoFilter string `json:"geo_filter,omitempty"` +} + +// CreateDNSRecord_Response is a response structure when creating +// a DNS record of a domain +type CreateDNSRecord_Response struct { + Message string `json:"message,omitempty"` + Status bool `json:"status,omitempty"` + Errors map[string][]string `json:"errors,omitempty"` + Data interface{} `json:"data,omitempty"` +} + +// ----------------------------------------------------------- DSN Operations - List + +// ListDNSRecordsParams is a structure for all needed parameters +// to list all DNS records of a domain +type ListDNSRecordsParams struct { + Search string `url:"search,omitempty"` + Type string `url:"type,omitempty"` + Page int `url:"page,omitempty"` + PerPage int `url:"per_page,omitempty"` +} + +// ListDNSRecord_Response is a response structure when listing +// DNS records of a domain +type ListDNSRecord_Response struct { + Data []DNSRecord `json:"data"` + Meta interface{} `json:"meta,omitempty"` + Links interface{} `json:"links,omitempty"` +} + +// ----------------------------------------------------------- DSN Operations - Update + +type UpdateDNSRecordParams = CreateDNSRecordParams + +// UpdateDNSRecord_Response is a response structure when updating +// a DNS record of a domain +type UpdateDNSRecord_Response = CreateDNSRecord_Response + +// ----------------------------------------------------------- DSN Operations - Delete + +// DeleteDNSRecord_Response is a response structure when deleting +// a DNS record of a domain +type DeleteDNSRecord_Response = CreateDNSRecord_Response diff --git a/pkg/dns_test.go b/pkg/dns_test.go new file mode 100644 index 0000000..f29c94a --- /dev/null +++ b/pkg/dns_test.go @@ -0,0 +1,523 @@ +package arvancloud + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCreateDNSRecord(t *testing.T) { + setup() + defer teardown() + + input := CreateDNSRecordParams{ + Type: "a", + Name: "@", + Value: []interface{}{ + map[string]interface{}{ + "ip": "1.2.3.4", + "port": 1.0, + "weight": 10.0, + "country": "", + }, + map[string]interface{}{ + "ip": "5.6.7.8", + "port": 2.0, + "weight": 20.0, + "country": "", + }, + }, + TTL: 120, + UpstreamHTTPS: "https", + IPFilterMode: map[string]interface{}{ + "count": "multi", + "order": "weighted", + "geo_filter": "none", + }, + } + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + + var p CreateDNSRecordParams + err := json.NewDecoder(r.Body).Decode(&p) + require.NoError(t, err) + assert.Equal(t, input, p) + + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, `{ + "data": { + "id": "714009ff-a43c-43c5-80e2-0b3ffc1344a4", + "type": "a", + "name": "@", + "value": [ + { + "ip": "1.2.3.4", + "port": 1, + "weight": 10, + "country": "" + }, + { + "ip": "5.6.7.8", + "port": 2, + "weight": 20, + "country": "" + } + ], + "ttl": 120, + "cloud": false, + "upstream_https": "https", + "ip_filter_mode": { + "count": "multi", + "order": "weighted", + "geo_filter": "none" + }, + "is_protected": false, + "created_at": "2023-03-31T08:57:51+00:00", + "updated_at": "2023-03-31T08:57:51+00:00" + }, + "message": "DNS record created successfully" + }`) + } + + mux.HandleFunc("/domains/"+testDomain+"/dns-records", handler) + + want := &CreateDNSRecord_Response{ + Data: map[string]interface{}{ + "id": "714009ff-a43c-43c5-80e2-0b3ffc1344a4", + "type": input.Type, + "name": input.Name, + "value": input.Value, + "ttl": float64(input.TTL), + "cloud": false, + "upstream_https": input.UpstreamHTTPS, + "ip_filter_mode": input.IPFilterMode, + "is_protected": false, + "created_at": "2023-03-31T08:57:51+00:00", + "updated_at": "2023-03-31T08:57:51+00:00", + }, + Message: "DNS record created successfully", + } + + _, err := client.CreateDNSRecord(context.Background(), ResourceDomain(""), CreateDNSRecordParams{}) + assert.ErrorIs(t, err, ErrMissingDomain) + + actual, err := client.CreateDNSRecord(context.Background(), ResourceDomain(testDomain), input) + require.NoError(t, err) + + assert.Equal(t, want, actual) +} + +func TestGetDNSRecord(t *testing.T) { + setup() + defer teardown() + + recordID := "714009ff-a43c-43c5-80e2-0b3ffc1344a4" + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method, "Expected method 'GET', got %s", r.Method) + + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, `{ + "data": { + "id": "714009ff-a43c-43c5-80e2-0b3ffc1344a4", + "type": "a", + "name": "@", + "value": [ + { + "ip": "1.2.3.4", + "port": 1, + "weight": 10, + "country": "" + }, + { + "ip": "5.6.7.8", + "port": 2, + "weight": 20, + "country": "" + } + ], + "ttl": 120, + "cloud": false, + "upstream_https": "https", + "ip_filter_mode": { + "count": "multi", + "order": "weighted", + "geo_filter": "none" + }, + "created_at": "2023-03-31T08:57:51+00:00", + "updated_at": "2023-03-31T08:57:51+00:00" + } + }`) + } + + mux.HandleFunc("/domains/"+testDomain+"/dns-records/"+recordID, handler) + + want := &DNSRecord{ + ID: recordID, + Type: "a", + Name: "@", + Value: []interface{}{ + map[string]interface{}{ + "ip": "1.2.3.4", + "port": 1.0, + "weight": 10.0, + "country": "", + }, + map[string]interface{}{ + "ip": "5.6.7.8", + "port": 2.0, + "weight": 20.0, + "country": "", + }, + }, + TTL: 120, + Cloud: false, + UpstreamHTTPS: "https", + IPFilterMode: map[string]interface{}{ + "count": "multi", + "order": "weighted", + "geo_filter": "none", + }, + CreatedAt: "2023-03-31T08:57:51+00:00", + UpdatedAt: "2023-03-31T08:57:51+00:00", + } + + _, err := client.GetDNSRecord(context.Background(), ResourceDomain(""), recordID) + assert.ErrorIs(t, err, ErrMissingDomain) + + _, err = client.GetDNSRecord(context.Background(), ResourceDomain(testDomain), "") + assert.ErrorIs(t, err, ErrMissingDNSRecordID) + + actual, err := client.GetDNSRecord(context.Background(), ResourceDomain(testDomain), recordID) + require.NoError(t, err) + + assert.Equal(t, want, actual) +} + +func TestListDNSRecords(t *testing.T) { + setup() + defer teardown() + + input := ListDNSRecordsParams{ + Search: "b", + Type: "A", + } + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method, "Expected method 'GET', got %s", r.Method) + assert.Equal(t, input.Search, r.URL.Query().Get("search")) + assert.Equal(t, input.Type, r.URL.Query().Get("type")) + + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, `{ + "data": [ + { + "id": "714009ff-a43c-43c5-80e2-0b3ffc1344a4", + "type": "a", + "name": "@", + "value": [ + { + "ip": "1.2.3.4", + "port": 1, + "weight": 10, + "country": "" + }, + { + "ip": "5.6.7.8", + "port": 2, + "weight": 20, + "country": "" + } + ], + "ttl": 180, + "cloud": false, + "upstream_https": "https", + "ip_filter_mode": { + "count": "multi", + "order": "weighted", + "geo_filter": "none" + }, + "created_at": "2023-03-31T08:57:51+00:00", + "updated_at": "2023-03-31T15:41:26+00:00" + }, + { + "id": "ee29f172-0b0d-4f2d-ba12-9587291936e3", + "type": "a", + "name": "b", + "value": [ + { + "ip": "3.1.8.1", + "port": 80, + "weight": 100, + "country": "" + }, + { + "ip": "4.8.1.2", + "port": 80, + "weight": 100, + "country": "" + } + ], + "ttl": 120, + "cloud": false, + "upstream_https": "https", + "ip_filter_mode": { + "count": "multi", + "order": "weighted", + "geo_filter": "none" + }, + "created_at": "2023-03-31T11:40:15+00:00", + "updated_at": "2023-03-31T11:40:15+00:00" + } + ], + "links": { + "first": "https://napi.arvancloud.ir/4.0/domains/wkmag.ir/dns-records?page=1", + "last": "https://napi.arvancloud.ir/4.0/domains/wkmag.ir/dns-records?page=1", + "prev": null, + "next": null + }, + "meta": { + "current_page": 1, + "from": 1, + "last_page": 1, + "links": [ + { + "url": null, + "label": "Previous", + "active": false + }, + { + "url": "https://napi.arvancloud.ir/4.0/domains/wkmag.ir/dns-records?page=1", + "label": "1", + "active": true + }, + { + "url": null, + "label": "Next", + "active": false + } + ], + "path": "https://napi.arvancloud.ir/4.0/domains/wkmag.ir/dns-records", + "per_page": 300, + "to": 2, + "total": 2 + } + }`) + } + + mux.HandleFunc("/domains/"+testDomain+"/dns-records", handler) + + want := []DNSRecord{{ + ID: "714009ff-a43c-43c5-80e2-0b3ffc1344a4", + Type: "a", + Name: "@", + Value: []interface{}{ + map[string]interface{}{ + "ip": "1.2.3.4", + "port": 1.0, + "weight": 10.0, + "country": "", + }, + map[string]interface{}{ + "ip": "5.6.7.8", + "port": 2.0, + "weight": 20.0, + "country": "", + }, + }, + TTL: 180, + Cloud: false, + UpstreamHTTPS: "https", + IPFilterMode: map[string]interface{}{ + "count": "multi", + "order": "weighted", + "geo_filter": "none", + }, + CreatedAt: "2023-03-31T08:57:51+00:00", + UpdatedAt: "2023-03-31T15:41:26+00:00", + }, + { + ID: "ee29f172-0b0d-4f2d-ba12-9587291936e3", + Type: "a", + Name: "b", + Value: []interface{}{ + map[string]interface{}{ + "ip": "3.1.8.1", + "port": 80.0, + "weight": 100.0, + "country": "", + }, + map[string]interface{}{ + "ip": "4.8.1.2", + "port": 80.0, + "weight": 100.0, + "country": "", + }, + }, + TTL: 120, + Cloud: false, + UpstreamHTTPS: "https", + IPFilterMode: map[string]interface{}{ + "count": "multi", + "order": "weighted", + "geo_filter": "none", + }, + CreatedAt: "2023-03-31T11:40:15+00:00", + UpdatedAt: "2023-03-31T11:40:15+00:00", + }, + } + + _, err := client.ListDNSRecords(context.Background(), ResourceDomain(""), ListDNSRecordsParams{}) + assert.ErrorIs(t, err, ErrMissingDomain) + + actual, err := client.ListDNSRecords(context.Background(), ResourceDomain(testDomain), input) + require.NoError(t, err) + + assert.Equal(t, want, actual) +} + +func TestUpdateDNSRecord(t *testing.T) { + setup() + defer teardown() + + recordID := "714009ff-a43c-43c5-80e2-0b3ffc1344a4" + + input := UpdateDNSRecordParams{ + Type: "a", + Name: "@", + Value: []interface{}{ + map[string]interface{}{ + "ip": "1.2.3.5", + "port": 2.0, + "weight": 20.0, + "country": "", + }, + map[string]interface{}{ + "ip": "5.6.7.9", + "port": 3.0, + "weight": 30.0, + "country": "", + }, + }, + TTL: 180, + UpstreamHTTPS: "http", + IPFilterMode: map[string]interface{}{ + "count": "multi", + "order": "weighted", + "geo_filter": "none", + }, + } + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method, "Expected method 'PUT', got %s", r.Method) + + var p UpdateDNSRecordParams + err := json.NewDecoder(r.Body).Decode(&p) + require.NoError(t, err) + assert.Equal(t, input, p) + + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, `{ + "data": { + "id": "714009ff-a43c-43c5-80e2-0b3ffc1344a4", + "type": "a", + "name": "@", + "value": [ + { + "ip": "1.2.3.5", + "port": 2, + "weight": 20, + "country": "" + }, + { + "ip": "5.6.7.9", + "port": 3, + "weight": 30, + "country": "" + } + ], + "ttl": 180, + "cloud": false, + "upstream_https": "http", + "ip_filter_mode": { + "count": "multi", + "order": "weighted", + "geo_filter": "none" + }, + "is_protected": false, + "created_at": "2023-03-31T08:57:51+00:00", + "updated_at": "2023-03-31T15:41:26+00:00" + }, + "message": "DNS record updated" + }`) + } + + mux.HandleFunc("/domains/"+testDomain+"/dns-records/"+recordID, handler) + + want := &UpdateDNSRecord_Response{ + Data: map[string]interface{}{ + "id": recordID, + "type": input.Type, + "name": input.Name, + "value": input.Value, + "ttl": float64(input.TTL), + "cloud": false, + "upstream_https": input.UpstreamHTTPS, + "ip_filter_mode": input.IPFilterMode, + "is_protected": false, + "created_at": "2023-03-31T08:57:51+00:00", + "updated_at": "2023-03-31T15:41:26+00:00", + }, + Message: "DNS record updated", + } + + _, err := client.UpdateDNSRecord(context.Background(), ResourceDomain(""), recordID, UpdateDNSRecordParams{}) + assert.ErrorIs(t, err, ErrMissingDomain) + + _, err = client.UpdateDNSRecord(context.Background(), ResourceDomain(testDomain), "", UpdateDNSRecordParams{}) + assert.ErrorIs(t, err, ErrMissingDNSRecordID) + + actual, err := client.UpdateDNSRecord(context.Background(), ResourceDomain(testDomain), recordID, input) + require.NoError(t, err) + + assert.Equal(t, want, actual) +} + +func TestDeleteDNSRecord(t *testing.T) { + setup() + defer teardown() + + recordID := "714009ff-a43c-43c5-80e2-0b3ffc1344a4" + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodDelete, r.Method, "Expected method 'DELETE', got %s", r.Method) + + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, `{ + "message": "DNS record deleted" + }`) + } + + mux.HandleFunc("/domains/"+testDomain+"/dns-records/"+recordID, handler) + + want := &DeleteDNSRecord_Response{ + Message: "DNS record deleted", + } + + _, err := client.DeleteDNSRecord(context.Background(), ResourceDomain(""), recordID) + assert.ErrorIs(t, err, ErrMissingDomain) + + _, err = client.DeleteDNSRecord(context.Background(), ResourceDomain(testDomain), "") + assert.ErrorIs(t, err, ErrMissingDNSRecordID) + + actual, err := client.DeleteDNSRecord(context.Background(), ResourceDomain(testDomain), recordID) + require.NoError(t, err) + + assert.Equal(t, want, actual) +} diff --git a/pkg/entity.go b/pkg/entity.go new file mode 100644 index 0000000..3799392 --- /dev/null +++ b/pkg/entity.go @@ -0,0 +1,11 @@ +package arvancloud + +type Resource struct { + Domain string +} + +func ResourceDomain(domain string) Resource { + return Resource{ + Domain: domain, + } +} diff --git a/pkg/errors.go b/pkg/errors.go new file mode 100644 index 0000000..7baae97 --- /dev/null +++ b/pkg/errors.go @@ -0,0 +1,156 @@ +package arvancloud + +import ( + "errors" + "strings" +) + +const ( + errEmptyAPIToken = "invalid credentials: API Token must not be empty" + + errMissingDomain = "required missing domain" + errMissingDNSRecordID = "required DNS record ID missing" + + errUnmarshalError = "error unmarshalling the JSON response" + errUnmarshalErrorBody = "error unmarshalling the JSON response error body" + + errInternalServiceError = "internal service error" +) + +const ( + ErrorTypeRequest ErrorType = "request" + ErrorTypeAuthentication ErrorType = "authentication" + ErrorTypeAuthorization ErrorType = "authorization" + ErrorTypeNotFound ErrorType = "not_found" + ErrorTypeRateLimit ErrorType = "rate_limit" + ErrorTypeService ErrorType = "service" +) + +var ( + ErrMissingDomain = errors.New(errMissingDomain) + ErrMissingDNSRecordID = errors.New(errMissingDNSRecordID) +) + +type ErrorType string + +// Error is the error returned by the ArvanCloud API. +type Error struct { + // The classification of error encountered. + Type ErrorType + + // StatusCode is the HTTP status code from the response. + Code int + + // Message is the error message. + Message string + + // Errors is a list of all the response error. + Errors map[string][]string +} + +// Error will return a human readable error message. +func (e Error) Error() string { + var errString string + errMessages := []string{} + m := "" + if e.Message != "" { + m += e.Message + } + + // Append the main error message to the slice of error messages + errMessages = append(errMessages, m) + + errors := []string{} + + // Loop through each error in the Errors slice + // Join them into a comma-separated string + // Append the string to the errors slice + for _, e := range e.Errors { + errors = append(errors, strings.Join(e, ", ")) + } + + // Join the error messages with commas and add it to the errString variable + errString += strings.Join(errMessages, ", ") + + // If there are errors in the errors slice + // Join them with a new line and add them to the errString variable + if len(errors) > 0 { + errString += "\n" + strings.Join(errors, " \n") + } + + return errString +} + +// RequestError is for 4xx errors. +type RequestError struct { + arvancloudError *Error +} + +// Error will return a human readable error message. +func (e RequestError) Error() string { + return e.arvancloudError.Error() +} + +// Errors will return a map of all the errors. +func (e RequestError) Errors() map[string][]string { + return e.arvancloudError.Errors +} + +// ErrorCode will return the HTTP status code. +func (e RequestError) ErrorCode() int { + return e.arvancloudError.Code +} + +// ErrorMessage will return the error message. +func (e RequestError) ErrorMessage() string { + return e.arvancloudError.Message +} + +// Type will return the error type. +func (e RequestError) Type() ErrorType { + return e.arvancloudError.Type +} + +// NewRequestError will return a new RequestError. +func NewRequestError(e *Error) RequestError { + return RequestError{ + arvancloudError: e, + } +} + +// ServiceError is for 5xx errors. +type ServiceError struct { + arvancloudError *Error +} + +// Error will return a human readable error message. +func (e ServiceError) Error() string { + return e.arvancloudError.Error() +} + +// Errors will return a map of all the errors. +func (e ServiceError) Errors() map[string][]string { + return e.arvancloudError.Errors +} + +// ErrorCode will return the HTTP status code. +func (e ServiceError) ErrorCode() int { + return e.arvancloudError.Code +} + +// ErrorMessage will return the error message. +func (e ServiceError) ErrorMessage() string { + return e.arvancloudError.Message +} + +// Type will return the error type. +func (e ServiceError) Type() ErrorType { + return e.arvancloudError.Type +} + +// NewServiceError will return a new ServiceError. +func NewServiceError(e *Error) ServiceError { + return ServiceError{ + arvancloudError: e, + } +} diff --git a/pkg/info.go b/pkg/info.go new file mode 100644 index 0000000..79a1011 --- /dev/null +++ b/pkg/info.go @@ -0,0 +1,18 @@ +package arvancloud + +const ( + // SCHEME is the scheme used to connect to the ArvanCloud API + SCHEME = "https" + + // HOST_NAME is the host name used to connect to the ArvanCloud API + HOST_NAME = "napi.arvancloud.ir" + + // BASE_PATH is the base path used to connect to the CDN API + BASE_PATH = "/cdn/4.0" + + // UA is the user agent of client + UA = "r1c-go" + + // VERSION is the version of client + VERSION = "v0.1.3" +) diff --git a/pkg/options.go b/pkg/options.go new file mode 100644 index 0000000..7c46504 --- /dev/null +++ b/pkg/options.go @@ -0,0 +1,71 @@ +package arvancloud + +import ( + "net/http" + + "time" + + "golang.org/x/time/rate" +) + +type Option func(*API) error + +// Headers will set custom HTTP headers for API calls +func Headers(headers http.Header) Option { + return func(api *API) error { + api.headers = headers + return nil + } +} + +// UsingRateLimit will apply a rate limit policy to API client +func UsingRateLimit(rps float64) Option { + return func(api *API) error { + api.rateLimiter = rate.NewLimiter(rate.Limit(rps), 1) + return nil + } +} + +// UsingRetryPolicy will apply a retry policy to API client +func UsingRetryPolicy(maxRetries int, minRetryDelaySecs int, maxRetryDelaySecs int) Option { + return func(api *API) error { + api.retryPolicy = RetryPolicy{ + MaxRetries: maxRetries, + MinRetryDelay: time.Duration(minRetryDelaySecs) * time.Second, + MaxRetryDelay: time.Duration(maxRetryDelaySecs) * time.Second, + } + + return nil + } +} + +// UsingLogger will apply a custom user agent to API client +func UserAgent(userAgent string) Option { + return func(api *API) error { + api.UserAgent = userAgent + + return nil + } +} + +// Debug will handle debugging mode for API client +func Debug(debug bool) Option { + return func(api *API) error { + api.Debug = debug + + return nil + } +} + +// parseOptions will parse the supplied options for API client +func (api *API) parseOptions(options ...Option) error { + for _, option := range options { + err := option(api) + + if err != nil { + return err + } + } + + return nil +} diff --git a/pkg/options_test.go b/pkg/options_test.go new file mode 100644 index 0000000..2f3d2df --- /dev/null +++ b/pkg/options_test.go @@ -0,0 +1,45 @@ +package arvancloud + +import ( + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "golang.org/x/time/rate" +) + +func TestParseOptions(t *testing.T) { + headers := make(http.Header) + headers.Add("H1", "V1") + headers.Add("H2", "V2") + headers.Add("H3", "V3") + + api, err := New( + "TEST_TOKEN", + Debug(true), + UserAgent("UA-test"), + Headers(headers), + UsingRateLimit(1.5), + UsingRetryPolicy(1, 2, 3), + ) + + if err != nil { + t.Errorf("Error ocurred: %v", err.Error()) + } + + assert.Equal(t, true, api.Debug) + + assert.Equal(t, "UA-test", api.UserAgent) + + assert.Equal(t, "V1", api.headers.Get("H1")) + assert.Equal(t, "V2", api.headers.Get("H2")) + assert.Equal(t, "V3", api.headers.Get("H3")) + + assert.Equal(t, 1, api.rateLimiter.Burst()) + assert.Equal(t, rate.Limit(1.5), api.rateLimiter.Limit()) + + assert.Equal(t, 1, api.retryPolicy.MaxRetries) + assert.Equal(t, time.Duration(2000000000), api.retryPolicy.MinRetryDelay) + assert.Equal(t, time.Duration(3000000000), api.retryPolicy.MaxRetryDelay) +} diff --git a/pkg/utils.go b/pkg/utils.go new file mode 100644 index 0000000..835e5c0 --- /dev/null +++ b/pkg/utils.go @@ -0,0 +1,26 @@ +package arvancloud + +import ( + "encoding/json" + "fmt" + "net/url" + + "github.com/google/go-querystring/query" +) + +func buildURI(path string, options interface{}) string { + v, _ := query.Values(options) + return (&url.URL{Path: path, RawQuery: v.Encode()}).String() +} + +// PrettyPrint will print the contents of the object +func PrettyPrint(data interface{}) { + p, err := json.MarshalIndent(data, "", "\t") + + if err != nil { + fmt.Println(err) + return + } + + fmt.Printf("%s \n", p) +} diff --git a/pkg/utils_test.go b/pkg/utils_test.go new file mode 100644 index 0000000..f7ecb2f --- /dev/null +++ b/pkg/utils_test.go @@ -0,0 +1,42 @@ +package arvancloud + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +type Pagination struct { + Page int `json:"page,omitempty" url:"page,omitempty"` + PerPage int `json:"per_page,omitempty" url:"per_page,omitempty"` +} + +type testExample struct { + A string `url:"a,omitempty"` + B string `url:"b,omitempty"` + Pagination +} + +func Test_buildURI(t *testing.T) { + tests := map[string]struct { + path string + params interface{} + want string + }{ + "multi level": {path: "/accounts/test.ir", params: testExample{}, want: "/accounts/test.ir"}, + "multi level - params": {path: "/domains/test.ir", params: testExample{A: "b"}, want: "/domains/test.ir?a=b"}, + "multi level - multi params": {path: "/domains/test.ir", params: testExample{A: "b", B: "d"}, want: "/domains/test.ir?a=b&b=d"}, + "multi level - nested fields": {path: "/domains/test.ir", params: testExample{A: "b", B: "d", Pagination: Pagination{PerPage: 10}}, want: "/domains/test.ir?a=b&b=d&per_page=10"}, + "single level": {path: "/test", params: testExample{}, want: "/test"}, + "single level - params": {path: "/test", params: testExample{B: "d"}, want: "/test?b=d"}, + "single level - multi params": {path: "/test", params: testExample{A: "b", B: "d"}, want: "/test?a=b&b=d"}, + "single level - nested fields": {path: "/test", params: testExample{A: "b", B: "d", Pagination: Pagination{PerPage: 10}}, want: "/test?a=b&b=d&per_page=10"}, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + got := buildURI(tc.path, tc.params) + assert.Equal(t, tc.want, got) + }) + } +}