From aa73fa2d328f35d578dc8946035f286f25495369 Mon Sep 17 00:00:00 2001 From: d-dot-one Date: Thu, 26 Oct 2023 15:17:28 -0600 Subject: [PATCH] initial commit --- .gitignore | 25 +++ .golangci.yaml | 340 +++++++++++++++++++++++++++++++++ .pre-commit-config.yaml | 138 +++++++++++++ CONTRIBUTING.md | 3 + LICENSE.md | 21 ++ Makefile | 101 ++++++++++ README.md | 128 +++++++++++++ SECURITY.md | 29 +++ client/build-and-test.yaml | 33 ++++ client/client.go | 232 ++++++++++++++++++++++ client/client_test.go | 239 +++++++++++++++++++++++ client/data_structures.go | 171 +++++++++++++++++ client/data_structures_test.go | 85 +++++++++ client/realtime_client.go | 39 ++++ go.mod | 7 + go.sum | 4 + 16 files changed, 1595 insertions(+) create mode 100644 .gitignore create mode 100644 .golangci.yaml create mode 100644 .pre-commit-config.yaml create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE.md create mode 100644 Makefile create mode 100644 README.md create mode 100644 SECURITY.md create mode 100644 client/build-and-test.yaml create mode 100644 client/client.go create mode 100644 client/client_test.go create mode 100644 client/data_structures.go create mode 100644 client/data_structures_test.go create mode 100644 client/realtime_client.go create mode 100644 go.mod create mode 100644 go.sum diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7a9ba09 --- /dev/null +++ b/.gitignore @@ -0,0 +1,25 @@ +# OS +.DS_Store + +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# Dependency directories (remove the comment below to include it) +# vendor/ + +# Go workspace file +go.work + +# ide +.idea/ +.idea/** diff --git a/.golangci.yaml b/.golangci.yaml new file mode 100644 index 0000000..29c5107 --- /dev/null +++ b/.golangci.yaml @@ -0,0 +1,340 @@ +# Options for analysis running. +run: + # The default concurrency value is the number of available CPU. + concurrency: 4 + # Timeout for analysis, e.g. 30s, 5m. + # Default: 1m + timeout: 5m + # Exit code when at least one issue was found. + # Default: 1 + issues-exit-code: 2 + # Include test files or not. + # Default: true + tests: false + # List of build tags, all linters use it. + # Default: []. + build-tags: + - mytag + # Which dirs to skip: issues from them won't be reported. + # Can use regexp here: `generated.*`, regexp is applied on full path, + # including the path prefix if one is set. + # Default value is empty list, + # but default dirs are skipped independently of this option's value (see skip-dirs-use-default). + # "/" will be replaced by current OS file path separator to properly work on Windows. + skip-dirs: + - src/external_libs + - autogenerated_by_my_lib + # Enables skipping of directories: + # - vendor$, third_party$, testdata$, examples$, Godeps$, builtin$ + # Default: true + skip-dirs-use-default: false + # Which files to skip: they will be analyzed, but issues from them won't be reported. + # Default value is empty list, + # but there is no need to include all autogenerated files, + # we confidently recognize autogenerated files. + # If it's not please let us know. + # "/" will be replaced by current OS file path separator to properly work on Windows. + skip-files: + - ".*\\.my\\.go$" + - lib/bad.go + # If set we pass it to "go list -mod={option}". From "go help modules": + # If invoked with -mod=readonly, the go command is disallowed from the implicit + # automatic updating of go.mod described above. Instead, it fails when any changes + # to go.mod are needed. This setting is most useful to check that go.mod does + # not need updates, such as in a continuous integration and testing system. + # If invoked with -mod=vendor, the go command assumes that the vendor + # directory holds the correct copies of dependencies and ignores + # the dependency descriptions in go.mod. + # + # Allowed values: readonly|vendor|mod + # By default, it isn't set. + modules-download-mode: readonly + # Allow multiple parallel golangci-lint instances running. + # If false (default) - golangci-lint acquires file lock on start. + allow-parallel-runners: false + # Define the Go version limit. + # Mainly related to generics support since go1.18. + # Default: use Go version from the go.mod file, fallback on the env var `GOVERSION`, fallback on 1.18 + go: '1.19' + +# output configuration options +output: + # Format: colored-line-number|line-number|json|colored-tab|tab|checkstyle|code-climate|junit-xml|github-actions|teamcity + # + # Multiple can be specified by separating them by comma, output can be provided + # for each of them by separating format name and path by colon symbol. + # Output path can be either `stdout`, `stderr` or path to the file to write to. + # Example: "checkstyle:report.xml,json:stdout,colored-line-number" + # + # Default: colored-line-number +# format: json + format: colored-line-number + # Print lines of code with issue. + # Default: true + print-issued-lines: false + # Print linter name in the end of issue text. + # Default: true + print-linter-name: false + # Make issues output unique by line. + # Default: true + uniq-by-line: false + # Add a prefix to the output file references. + # Default is no prefix. + path-prefix: "" + # Sort results by: filepath, line and column. + sort-results: false + +#linters: + # Disable all linters. + # Default: false +# disable-all: true +# enable-all: true + # Enable specific linter + # https://golangci-lint.run/usage/linters/#enabled-by-default +# enable: +# - asasalint +# - asciicheck +# - bidichk +# - bodyclose +# - containedctx +# - contextcheck +# - cyclop +# - decorder +# - depguard +# - dogsled +# - dupl +# - dupword +# - durationcheck +# - errcheck +# - errchkjson +# - errname +# - errorlint +# - execinquery +# - exhaustive +# - exhaustruct +# - exhaustruct +# - exportloopref +# - forbidigo +# - forcetypeassert +# - funlen +# - gci +# - ginkgolinter +# - gocheckcompilerdirectives +# - gochecknoglobals +# - gochecknoinits +## - gochecksumtype +# - gocognit +# - goconst +# - gocritic +# - gocyclo +# - godot +# - godox +# - goerr113 +# - gofmt +# - gofumpt +# - goheader +# - goimports +# - revive +# - gomnd +# - gomoddirectives +# - gomodguard +# - goprintffuncname +# - gosec +# - gosimple +# - gosmopolitan +# - govet +# - grouper +## - ifshort +# - importas +## - inamedparam +# - ineffassign +# - interfacebloat +# - ireturn +# - lll +# - loggercheck +# - maintidx +# - makezero +# - mirror +# - misspell +# - musttag +# - nakedret +# - nestif +# - nilerr +# - nilnil +# - nlreturn +# - noctx +# - nolintlint +# - nonamedreturns +# - nosprintfhostport +# - paralleltest +## - perfsprint +# - prealloc +# - predeclared +# - promlinter +## - protogetter +# - reassign +# - revive +# - rowserrcheck +# - exportloopref +## - sloglint +# - sqlclosecheck +# - staticcheck +# - unused +# - stylecheck +# - tagalign +# - tagliatelle +# - tenv +# - testableexamples +## - testifylint +# - testpackage +# - thelper +# - tparallel +# - typecheck +# - unconvert +# - unparam +# - unused +# - usestdlibvars +# - varnamelen +# - wastedassign +# - whitespace +# - wrapcheck +# - wsl +# - zerologlint + # Enable all available linters. + # Default: false +# enable-all: true + # Disable specific linter + # https://golangci-lint.run/usage/linters/#disabled-by-default +# disable: +# - asasalint +# - asciicheck +# - bidichk +# - bodyclose +# - containedctx +# - contextcheck +# - cyclop +# - deadcode +# - decorder +# - depguard +# - dogsled +# - dupl +# - dupword +# - durationcheck +# - errcheck +# - errchkjson +# - errname +# - errorlint +# - execinquery +# - exhaustive +# - exhaustivestruct +# - exhaustruct +# - exportloopref +# - forbidigo +# - forcetypeassert +# - funlen +# - gci +# - ginkgolinter +# - gocheckcompilerdirectives +# - gochecknoglobals +# - gochecknoinits +## - gochecksumtype +# - gocognit +# - goconst +# - gocritic +# - gocyclo +# - godot +# - godox +# - goerr113 +# - gofmt +# - gofumpt +# - goheader +# - goimports +# - golint +# - gomnd +# - gomoddirectives +# - gomodguard +# - goprintffuncname +# - gosec +# - gosimple +# - gosmopolitan +# - govet +# - grouper +# - ifshort +# - importas +## - inamedparam +# - ineffassign +# - interfacebloat +# - interfacer +# - ireturn +# - lll +# - loggercheck +# - maintidx +# - makezero +# - maligned +# - mirror +# - misspell +# - musttag +# - nakedret +# - nestif +# - nilerr +# - nilnil +# - nlreturn +# - noctx +# - nolintlint +# - nonamedreturns +# - nosnakecase +# - nosprintfhostport +# - paralleltest +## - perfsprint +# - prealloc +# - predeclared +# - promlinter +## - protogetter +# - reassign +# - revive +# - rowserrcheck +# - scopelint +## - sloglint +# - sqlclosecheck +# - staticcheck +# - structcheck +# - stylecheck +# - tagalign +# - tagliatelle +# - tenv +# - testableexamples +## - testifylint +# - testpackage +# - thelper +# - tparallel +# - typecheck +# - unconvert +# - unparam +# - unused +# - usestdlibvars +# - varcheck +# - varnamelen +# - wastedassign +# - whitespace +# - wrapcheck +# - wsl +# - zerologlint +# # Enable presets. +# # https://golangci-lint.run/usage/linters +# presets: +# - bugs +# - comment +# - complexity +# - error +# - format +# - import +# - metalinter +# - module +# - performance +# - sql +# - style +# - test +# - unused + # Run only fast linters from enabled linters set (first run won't be fast) + # Default: false + fast: true diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..a262f51 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,138 @@ +default_stages: [commit] +fail_fast: false +minimum_pre_commit_version: 2.15.0 + +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.0.1 + hooks: + - id: check-added-large-files + args: + - --maxkb=1500 + - id: check-ast + - id: check-builtin-literals + - id: check-case-conflict + - id: check-docstring-first + - id: check-executables-have-shebangs + - id: check-json + exclude: manual-artifacts/tailscale/ + - id: check-merge-conflict + - id: check-shebang-scripts-are-executable + - id: check-symlinks + - id: check-yaml + exclude: template.yaml + - id: debug-statements + - id: destroyed-symlinks + - id: detect-aws-credentials + args: + - --allow-missing-credentials + - id: detect-private-key + - id: double-quote-string-fixer + - id: end-of-file-fixer + - id: name-tests-test + args: + - --django + - id: trailing-whitespace + + - repo: https://github.com/pre-commit/mirrors-isort + rev: v5.4.2 + hooks: + - id: isort + + - repo: local + hooks: + - id: pylint + name: pylint + entry: pylint + exclude: ^tests + language: system + types: [python] + require_serial: true + args: + # general config + - --ignore=CVS + - --ignore=tests + - --persistent=no +# - --load-plugins=pylint-quotes + - --extension-pkg-whitelist=lxml + # disables + - --disable=broad-except + - --disable=broad-exception-raised + - --disable=consider-iterating-dictionary + - --disable=logging-fstring-interpolation + - --disable=logging-format-interpolation + - --disable=missing-module-docstring + - --disable=too-few-public-methods + - --disable=unused-variable + - --disable=invalid-name + # reports + - --output-format=text + - --reports=no + - --evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) + # variables + - --init-import=yes + - --dummy-variables-rgx=dummy|unused + - --additional-builtins=_ + # classes + - --defining-attr-methods=__init__,__new__,setUp + - --valid-classmethod-first-arg=cls + # naming + - --module-rgx=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$ + - --const-rgx=(([A-Z_][A-Z0-9_]*)|(__.*__))$ + - --class-rgx=[A-Z_][a-zA-Z0-9]+$ + - --function-rgx=[a-z_][a-zA-Z0-9_]{2,45}$ + - --method-rgx=[a-zA-Z_][a-zA-Z0-9_]{2,50}$ + - --attr-rgx=[a-z_][a-zA-Z0-9_]{2,30}$ + - --argument-rgx=[a-z_][a-zA-Z0-9_]{2,30} + - --variable-rgx=[a-z_][a-zA-Z0-9_]{2,30}$|[a-z] + - --inlinevar-rgx=[A-Za-z_][A-Za-z0-9_]*$ + - --good-names=i,j,_,x,y,z,N,E,S,W,id,logger + - --bad-names=foo,bar,baz,toto,tutu,tata,zzyzx + - --no-docstring-rgx=__.*__ + # notes + - --notes=FIXME,fixme,TODO,todo,FIX,fix,\\todo,@todo + # typecheck + - --ignore-mixin-members=yes + # formatting + - --max-line-length=120 + - --max-module-lines=1200 + - --indent-string=' ' +# - --string-quote=single +# - --triple-quote=single +# - --docstring-quote=double + # similarities + - --min-similarity-lines=7 + - --ignore-comments=yes + - --ignore-docstrings=yes + # design + - --max-args=10 + - --ignored-argument-names=_.*|event + - --max-locals=20 + - --max-returns=6 + - --max-branches=20 + - --max-statements=50 + - --max-parents=7 + - --max-attributes=15 + - --min-public-methods=2 + - --max-public-methods=30 + # imports + - --known-standard-library=yes + - --known-third-party=yes + - --analyse-fallback-blocks=yes + + - repo: https://github.com/antonbabenko/pre-commit-terraform + rev: v1.55.0 + hooks: +# - id: terraform_fmt +# args: +# - --args=-recursive +# - --args=-diff +# - id: terraform_validate +# - id: terraform_docs +# args: +# - --hook-config=--path-to-file=SPECS.md +# - --hook-config=--add-to-existing-file=true +# - --hook-config=--create-file-if-not-exist=true + - id: terraform_tflint + args: + - --args=--config=__GIT_WORKING_DIR__/.tflint.hcl diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..d098767 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,3 @@ +# Contribute to this project + +More information will be coming soon. diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..d0c4725 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) [2023] [d-dot-one] + +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..7afea9d --- /dev/null +++ b/Makefile @@ -0,0 +1,101 @@ +# Change these variables as necessary. +# Stolen from https://www.alexedwards.net/blog/a-time-saving-makefile-for-your-go-projects +# +MAIN_PACKAGE_PATH := ./cmd/example +BINARY_NAME := example + +# ==================================================================================== # +# HELPERS +# ==================================================================================== # + +## help: print this help message +.PHONY: help +help: + @echo 'Usage:' + @sed -n 's/^##//p' ${MAKEFILE_LIST} | column -t -s ':' | sed -e 's/^/ /' + +.PHONY: confirm +confirm: + @echo -n 'Are you sure? [y/N] ' && read ans && [ $${ans:-N} = y ] + +.PHONY: no-dirty +no-dirty: + git diff --exit-code + + +# ==================================================================================== # +# QUALITY CONTROL +# ==================================================================================== # + +## tidy: format code and tidy modfile +.PHONY: tidy +tidy: + go fmt ./... + go mod tidy -v + +## audit: run quality control checks +.PHONY: audit +audit: + go mod verify + go vet ./... + go run honnef.co/go/tools/cmd/staticcheck@latest -checks=all,-ST1000,-U1000 ./... + go run golang.org/x/vuln/cmd/govulncheck@latest ./... + go test -race -buildvcs -vet=off ./... + + +# ==================================================================================== # +# DEVELOPMENT +# ==================================================================================== # + +## lint: run linter +.PHONE: lint +lint: + golangci-lint run + +## test: run all tests +.PHONY: test +test: + go test -v -race -buildvcs ./... + +## test/cover: run all tests and display coverage +.PHONY: test/cover +test/cover: + go test -v -race -buildvcs -coverprofile=/tmp/coverage.out ./... + go tool cover -html=/tmp/coverage.out + +## build: build the application +.PHONY: build +build: + # Include additional build steps, like TypeScript, SCSS or Tailwind compilation here... + go build -o=/tmp/bin/${BINARY_NAME} ${MAIN_PACKAGE_PATH} + +## run: run the application +.PHONY: run +run: build + /tmp/bin/${BINARY_NAME} + +## run/live: run the application with reloading on file changes +.PHONY: run/live +run/live: + go run github.com/cosmtrek/air@v1.43.0 \ + --build.cmd "make build" --build.bin "/tmp/bin/${BINARY_NAME}" --build.delay "100" \ + --build.exclude_dir "" \ + --build.include_ext "go, tpl, tmpl, html, css, scss, js, ts, sql, jpeg, jpg, gif, png, bmp, svg, webp, ico" \ + --misc.clean_on_exit "true" + + +# ==================================================================================== # +# OPERATIONS +# ==================================================================================== # + +## push: push changes to the remote Git repository +.PHONY: push +push: tidy audit no-dirty + git push + +## production/deploy: deploy the application to production +.PHONY: production/deploy +production/deploy: confirm tidy audit no-dirty + GOOS=linux GOARCH=amd64 go build -ldflags='-s' -o=/tmp/bin/linux_amd64/${BINARY_NAME} ${MAIN_PACKAGE_PATH} + upx -5 /tmp/bin/linux_amd64/${BINARY_NAME} + # Include additional deployment steps here... diff --git a/README.md b/README.md new file mode 100644 index 0000000..c5fd341 --- /dev/null +++ b/README.md @@ -0,0 +1,128 @@ +# Ambient Weather Network API Client + +## WIP +1662409800000 = 2022-09-06 14:30:00 +1662496200000 = 2022-09-06 14:30:00 + +## Overview + +This is a feature-complete Go version of a client that can connect to the Ambient Weather Network API in order to pull information about your weather station and the data that it has collected. It supports the normal API as well as the Websockets-based realtime API. + +## Installation + +```bash +go get github.com/d-dot-one/ambient-weather-network-client +``` +... or you can simply import it in your project and use it. + +```go +import "github.com/d-dot-one/ambient-weather-network-client" +``` +You'll need to do a `go get` in the terminal to actually fetch the package. + +## Environment Variables + +In order for all of this to work, you will need the following environment variables: + +| Variable | Required | Description | +|-----------------|----------|----------------------------------------------| +| `AWN_API_KEY` | Yes | Your Ambient Weather Network API key | +| `AWN_APP_KEY` | Yes | Your Ambient Weather Network application key | +| `AWN_LOG_LEVEL` | No | The log level to use. Defaults to `info` | + + +## Usage + +### Get Weather Station Data +To fetch the current weather and the weather station device data, you can use the following code: + +```go +package main + +import ( + "context" + "fmt" + + client "github.com/d-dot-one/ambient-weather-network-client" +) + +func main() { + // create a context + ctx, cancel := context.WithTimeout(context.Background(), contextTimeout) + defer cancel() + + // fetch required environment variables + requiredVars := []string{"AWN_API_KEY", "AWN_APP_KEY", "AWN_LOG_LEVEL"} + environmentVariables := client.GetEnvVars(requiredVars) + + // set the API key + apiKey := fmt.Sprintf("%v", environmentVariables["AWN_API_KEY"]) + + // set the application key + appKey := fmt.Sprintf("%v", environmentVariables["AWN_APP_KEY"]) + + // create an object to hold the API configuration + ApiConfig := client.CreateApiConfig(apiKey, appKey, ctx) + + // fetch the device data and return it as an AmbientDevice + data, err := client.GetDevices(ApiConfig) + client.CheckReturn(err, "failed to get devices", "critical") + + // see the MAC address of the weather station + fmt.Println(data.MacAddress) +} +``` + +### Get Historical Weather Station Data +```go +package main + +import ( + "context" + "fmt" + + client "github.com/d-dot-one/ambient-weather-network-client" +) + +func main() { + // create a context + ctx, cancel := context.WithTimeout(context.Background(), contextTimeout) + defer cancel() + + // fetch required environment variables + requiredVars := []string{"AWN_API_KEY", "AWN_APP_KEY", "AWN_LOG_LEVEL"} + environmentVariables := client.GetEnvVars(requiredVars) + + // set the API key + apiKey := fmt.Sprintf("%v", environmentVariables["AWN_API_KEY"]) + + // set the application key + appKey := fmt.Sprintf("%v", environmentVariables["AWN_APP_KEY"]) + + // create an object to hold the API configuration + ApiConfig := client.CreateApiConfig(apiKey, appKey, ctx) + + // fetch the device data and return it as an AmbientDevice + data, err := client.GetDevices(ApiConfig) + client.CheckReturn(err, "failed to get devices", "critical") + + // see the MAC address of the weather station + fmt.Println(data.MacAddress) +} +``` + +## Dependencies + +I purposefully chose to use as few dependencies as possible for this project. I wanted to keep it as simple and close to the standard library. The only exception is the `resty` library, which is used to make the API calls. It was too helpful with retries to not use it. + +## Constrictions + +The Ambient Weather API has a cap on the number of API calls that one can make in a given second. This is set to 1 call per second. This means that if you have more than one weather station, you will need to make sure that you are not making more than 1 call per second. This is done by using a `time.Sleep(1 * time.Second)` after each API call. Generally speaking, this is all handled in the background with a retry mechanism, but it is something to be aware of. + +## Contributing + +You are more than welcome to submit a PR if you would like to contribute to this project. I am always open to suggestions and improvements. Reference [CONTRIBUTING.md](./CONTRIBUTING.md) for more information. + +## License + +This package is made available under an MIT license. See [LICENSE.md](./LICENSE.md) for more information. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..3dfc767 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,29 @@ +# Security +We take the security of this software seriously. If you believe you found a security issue or a vulnerability in this software, please report it as described below. + +## How to Report a Vulnerability +Please **do not** report a security issue or vulnerability through the public-facing GitHub Issues. + +Instead, report the issue or vulnerability directly to the maintainers of this GitHub Action at **[d-dot-one[at]proton. +me](mailto:d-dot-one[at]proton.me)**. You will receive a response from us within 48 hours. If the issue is confirmed, +we will release a patch as soon as possible, depending on complexity but historically within a few days. + +Please include the information below (as much as possible) to help us better understand the issue: + +* Type of issue (ex. buffer overflow, remote code execution, authentication/authorization bypass, etc.) +* The location of the affected source code (tag/branch/commit or URL) +* Any special configuration required to reproduce the issue +* Step-by-step instructions to reproduce the issue +* Proof-of-concept or exploit code (if available) +* Impact of the issue, including how an attacker might exploit the issue + +This information will be helpful for us to identify and correct the issue. + +## Supported Versions + +We release patches for security vulnerabilities. Which versions are eligible for receiving such patches depends on the CVSS v3.0 Rating: + +| CVSS v3.0 | Supported Versions | +| --------- | ----------------------------------------- | +| 9.0-10.0 | Releases within the previous three months | +| 4.0-8.9 | Most recent release | diff --git a/client/build-and-test.yaml b/client/build-and-test.yaml new file mode 100644 index 0000000..5ad6a65 --- /dev/null +++ b/client/build-and-test.yaml @@ -0,0 +1,33 @@ +name: build-and-test + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + build: + runs-on: ubuntu-latest + defaults: + run: + working-directory: . + shell: bash + + steps: + - name: checkout + uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c # v3 + + - name: install go + uses: actions/setup-go@93397bea11091df50f3d7e59dc26a7711a8bcfbe # v4.1.0 + with: + go-version: '^1.21.0' + + - name: install dependencies + run: go mod download + + - name: build + run: go build ./... + + - name: test + run: go test -cover ./... diff --git a/client/client.go b/client/client.go new file mode 100644 index 0000000..000ea0a --- /dev/null +++ b/client/client.go @@ -0,0 +1,232 @@ +// Package client is a client that can access the Ambient Weather network API and +// return device and weather data. +package client + +import ( + "context" + "errors" + "log" + "net/http" + "os" + "strconv" + "sync" + "time" + + "gopkg.in/resty.v1" +) + +const ( + // apiVersion is a string and describes the version of the API that Ambient Weather + // is using. + apiVersion = "/v1" + + // baseURL The base URL for the Ambient Weather API (Not the real-time API) as a string. + baseURL = "https://rt.ambientweather.net" + + // debugMode Enable verbose logging by setting this boolean value to true. + debugMode = false + + // defaultCtxTimeout Set the context timeout, in seconds, as an int. + defaultCtxTimeout = 30 + + // devicesEndpoint The 'devices' endpoint as a string. + devicesEndpoint = "devices" + + // epochIncrement24h is the number of milliseconds in a 24-hour period. + epochIncrement24h int64 = 86400000 + + // retryCount An integer describing the number of times to retry in case of + // failure or rate limiting. + retryCount = 3 + + // retryMaxWaitTimeSeconds An integer describing the maximum time to wait to + // retry an API call, in seconds. + retryMaxWaitTimeSeconds = 15 + + // retryMinWaitTimeSeconds An integer describing the minimum time to wait to retry + // an API call, in seconds. + retryMinWaitTimeSeconds = 5 +) + +// The ConvertTimeToEpoch help function can convert any Go time.Time object to a Unix epoch time in milliseconds. +// func ConvertTimeToEpoch(t time.Time) int64 { +// return t.UnixMilli() +//} + +// The createAwnClient function is used to create a new resty-based API client. This client +// supports retries and can be placed into debug mode when needed. By default, it will +// also set the accept content type to JSON. Finally, it returns a pointer to the client. +func createAwnClient() *resty.Client { + client := resty.New(). + SetRetryCount(retryCount). + SetRetryWaitTime(retryMinWaitTimeSeconds * time.Second). + SetRetryMaxWaitTime(retryMaxWaitTimeSeconds * time.Second). + SetHostURL(baseURL + apiVersion). + SetTimeout(defaultCtxTimeout * time.Second). + SetDebug(debugMode). + AddRetryCondition( + func(r *resty.Response) (bool, error) { + return r.StatusCode() == http.StatusRequestTimeout || + r.StatusCode() >= http.StatusInternalServerError || + r.StatusCode() == http.StatusTooManyRequests, error(nil) + }) + + client.SetHeader("Accept", "application/json") + + return client +} + +// CreateAPIConfig is a helper function that is used to create the FunctionData struct, +// which is passed to the data gathering functions. +func CreateAPIConfig(api string, app string) FunctionData { + functionData := FunctionData{ + Api: api, + App: app, + Ct: createAwnClient(), + } + + return functionData +} + +// The GetDevices function takes a client, sets the appropriate query parameters for +// authentication, makes the request to the devicesEndpoint endpoint and marshals the +// response data into a pointer to an AmbientDevice object, which is returned along with +// any error messages. +func GetDevices(ctx context.Context, funcData FunctionData) (AmbientDevice, error) { + funcData.Ct.SetQueryParams(map[string]string{ + "apiKey": funcData.Api, + "applicationKey": funcData.App, + }) + + deviceData := &AmbientDevice{} + + _, err := funcData.Ct.R().SetResult(deviceData).Get(devicesEndpoint) + CheckReturn(err, "unable to handle data from devicesEndpoint", "warning") + + if errors.Is(ctx.Err(), context.DeadlineExceeded) { + return nil, errors.New("context timeout exceeded") + } + + return *deviceData, err +} + +// The getDeviceData function takes a client and the Ambient Weather device MAC address +// as inputs. It then sets the query parameters for authentication and the maximum +// number of records to fetch in this API call to the macAddress endpoint. The response +// data is then marshaled into a pointer to a DeviceDataResponse object which is +// returned to the caller along with any errors. +func getDeviceData(ctx context.Context, funcData FunctionData) (DeviceDataResponse, error) { + funcData.Ct.SetQueryParams(map[string]string{ + "apiKey": funcData.Api, + "applicationKey": funcData.App, + "endDate": strconv.FormatInt(funcData.Epoch, 10), + "limit": strconv.Itoa(funcData.Limit), + }) + + deviceData := &DeviceDataResponse{} + + _, err := funcData.Ct.R(). + SetPathParams(map[string]string{ + "devicesEndpoint": devicesEndpoint, + "macAddress": funcData.Mac, + }). + SetResult(deviceData). + Get("{devicesEndpoint}/{macAddress}") + CheckReturn(err, "unable to handle data from the devices endpoint", "warning") + // todo: check call for errors passed through resp + // if mac is missing, you get the devices endpoint response, so test for mac address + // if apiKey is missing, you get {"error": "apiKey-missing"} + // if appKey is missing, you get {"error": "applicationKey-missing"} + // if date is wrong, you get {"error":"date-invalid","message":"Please refer + // to: http://momentjs.com/docs/#/parsing/string/"} + + if errors.Is(ctx.Err(), context.DeadlineExceeded) { + return nil, errors.New("ctx timeout exceeded") + } + + return *deviceData, err +} + +// The GetHistoricalData function takes a FunctionData object as input and returns a and +// will return a list of client.DeviceDataResponse object. +func GetHistoricalData(ctx context.Context, funcData FunctionData) ([]DeviceDataResponse, error) { + var deviceResponse []DeviceDataResponse + + for i := funcData.Epoch; i <= time.Now().UnixMilli(); i += epochIncrement24h { + funcData.Epoch = i + + resp, err := getDeviceData(ctx, funcData) + CheckReturn(err, "unable to get device data", "warning") + + deviceResponse = append(deviceResponse, resp) + } + + return deviceResponse, nil +} + +type LogLevelForError string + +// CheckReturn is a helper function to remove the usual error checking cruft. +func CheckReturn(err error, msg string, level LogLevelForError) { + if err != nil { + switch level { + case "panic": + log.Panicf("%v: %v", msg, err) + case "fatal": + log.Fatalf("%v: %v", msg, err) + case "warning": + log.Printf("%v: %v\n", msg, err) + case "info": + log.Printf("%v: %v\n", msg, err) + case "debug": + log.Printf("%v: %x\n", msg, err) + } + } +} + +func GetHistoricalDataAsync( + ctx context.Context, + funcData FunctionData, + w *sync.WaitGroup) (<-chan DeviceDataResponse, error) { + defer w.Done() + + out := make(chan DeviceDataResponse) + + go func() { + for i := funcData.Epoch; i <= time.Now().UnixMilli(); i += epochIncrement24h { + funcData.Epoch = i + + resp, err := getDeviceData(ctx, funcData) + CheckReturn(err, "unable to get device data", "warning") + + out <- resp + } + close(out) + }() + + return out, nil +} + +// GetEnvVars is a public function that will attempt to read the environment variables that +// are passed in as a list of strings. It will return a map of the environment variables. +func GetEnvVars(vars []string) map[string]string { + envVars := make(map[string]string) + + for v := range vars { + value := GetEnvVar(vars[v], "") + envVars[vars[v]] = value + } + + return envVars +} + +// GetEnvVar is a public function attempts to fetch an environment variable. If that +// environment variable is not found, it will return 'fallback'. +func GetEnvVar(key string, fallback string) string { + value, exists := os.LookupEnv(key) + if !exists { + value = fallback + } + + return value +} diff --git a/client/client_test.go b/client/client_test.go new file mode 100644 index 0000000..423aa19 --- /dev/null +++ b/client/client_test.go @@ -0,0 +1,239 @@ +package client + +import ( + "context" + "os" + "reflect" + "testing" + "time" +) + +func TestCheckReturn(t *testing.T) { + t.Parallel() + type args struct { + e error + msg string + level LogLevelForError + } + tests := []struct { + name string + args args + }{ + {"TestCheckReturnDebug", args{nil, "Debug log message", "debug"}}, + {"TestCheckReturnInfo", args{nil, "Info log message", "info"}}, + {"TestCheckReturnWarning", args{nil, "Warning log message", "warning"}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + CheckReturn(tt.args.e, tt.args.msg, tt.args.level) + }) + } +} + +func TestCreateApiConfig(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithTimeout(context.Background(), time.Second*3) + defer cancel() + + type args struct { + api string + app string + ctx context.Context + } + tests := []struct { + name string + args args + want FunctionData + }{ + {"TestCreateApiConfig", args{"api", "app", ctx}, FunctionData{"api", "app", nil, ctx, 0, 0, ""}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := CreateAPIConfig(tt.args.ctx, tt.args.api, tt.args.app); !reflect.DeepEqual(got, tt.want) { + t.Errorf("CreateAPIConfig() = %v, want %v", got, tt.want) + } + }) + } +} + +// func TestGetDevices(t *testing.T) { +// t.Parallel() +// type args struct { +// f FunctionData +// } +// tests := []struct { +// name string +// args args +// want AmbientDevice +// wantErr bool +// }{ +// // TODO: Add test cases. +// } +// for _, tt := range tests { +// t.Run(tt.name, func(t *testing.T) { +// got, err := GetDevices(tt.args.f) +// if (err != nil) != tt.wantErr { +// t.Errorf("GetDevices() error = %v, wantErr %v", err, tt.wantErr) +// return +// } +// if !reflect.DeepEqual(got, tt.want) { +// t.Errorf("GetDevices() got = %v, want %v", got, tt.want) +// } +// }) +// } +// } +func TestGetEnvVar(t *testing.T) { + t.Parallel() + err := os.Setenv("TEST_ENV_VAR", "test") + if err != nil { + t.Errorf("unable to set test environment variable") + } + defer os.Unsetenv("TEST_ENV_VAR") + + type args struct { + key string + fallback string + } + tests := []struct { + name string + args args + want string + }{ + {"TestGetEnvVar", args{"TEST_ENV_VAR", "fallback"}, "test"}, + {"TestGetEnvVarEmpty", args{"TEST_ENV_VAR_EMPTY", "fallback"}, "fallback"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := GetEnvVar(tt.args.key, tt.args.fallback); got != tt.want { + t.Errorf("GetEnvVar() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestGetEnvVars(t *testing.T) { + t.Parallel() + err := os.Setenv("TEST_ENV_VAR", "test") + if err != nil { + t.Errorf("unable to set test environment variable") + } + defer os.Unsetenv("TEST_ENV_VAR") + + type args struct { + vars []string + } + tests := []struct { + name string + args args + want map[string]string + }{ + {"TestGetEnvVars", args{[]string{"TEST_ENV_VAR", "ANOTHER_TEST_ENV_VAR"}}, map[string]string{"TEST_ENV_VAR": "test", "ANOTHER_TEST_ENV_VAR": ""}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := GetEnvVars(tt.args.vars); !reflect.DeepEqual(got, tt.want) { + t.Errorf("GetEnvVars() = %v, want %v", got, tt.want) + } + }) + } +} + +//func TestGetHistoricalData(t *testing.T) { +// t.Parallel() +// type args struct { +// f FunctionData +// } +// tests := []struct { +// name string +// args args +// want []DeviceDataResponse +// wantErr bool +// }{ +// // TODO: Add test cases. +// } +// for _, tt := range tests { +// t.Run(tt.name, func(t *testing.T) { +// got, err := GetHistoricalData(tt.args.f) +// if (err != nil) != tt.wantErr { +// t.Errorf("GetHistoricalData() error = %v, wantErr %v", err, tt.wantErr) +// return +// } +// if !reflect.DeepEqual(got, tt.want) { +// t.Errorf("GetHistoricalData() got = %v, want %v", got, tt.want) +// } +// }) +// } +//} +// +//func TestGetHistoricalDataAsync(t *testing.T) { +// t.Parallel() +// type args struct { +// f FunctionData +// w *sync.WaitGroup +// } +// tests := []struct { +// name string +// args args +// want <-chan DeviceDataResponse +// wantErr bool +// }{ +// // TODO: Add test cases. +// } +// for _, tt := range tests { +// t.Run(tt.name, func(t *testing.T) { +// got, err := GetHistoricalDataAsync(tt.args.f, tt.args.w) +// if (err != nil) != tt.wantErr { +// t.Errorf("GetHistoricalDataAsync() error = %v, wantErr %v", err, tt.wantErr) +// return +// } +// if !reflect.DeepEqual(got, tt.want) { +// t.Errorf("GetHistoricalDataAsync() got = %v, want %v", got, tt.want) +// } +// }) +// } +//} +// +//func Test_createAwnClient(t *testing.T) { +// t.Parallel() +// tests := []struct { +// name string +// want *resty.Client +// }{ +// // TODO: Add test cases. +// } +// for _, tt := range tests { +// t.Run(tt.name, func(t *testing.T) { +// if got := createAwnClient(); !reflect.DeepEqual(got, tt.want) { +// t.Errorf("createAwnClient() = %v, want %v", got, tt.want) +// } +// }) +// } +//} +// +//func Test_getDeviceData(t *testing.T) { +// t.Parallel() +// type args struct { +// f FunctionData +// } +// tests := []struct { +// name string +// args args +// want DeviceDataResponse +// wantErr bool +// }{ +// // TODO: Add test cases. +// } +// for _, tt := range tests { +// t.Run(tt.name, func(t *testing.T) { +// got, err := getDeviceData(tt.args.f) +// if (err != nil) != tt.wantErr { +// t.Errorf("getDeviceData() error = %v, wantErr %v", err, tt.wantErr) +// return +// } +// if !reflect.DeepEqual(got, tt.want) { +// t.Errorf("getDeviceData() got = %v, want %v", got, tt.want) +// } +// }) +// } +//} diff --git a/client/data_structures.go b/client/data_structures.go new file mode 100644 index 0000000..0d46e8a --- /dev/null +++ b/client/data_structures.go @@ -0,0 +1,171 @@ +package client + +import ( + "encoding/json" + "fmt" + "time" + + "gopkg.in/resty.v1" +) + +// FunctionData is a struct that is used to pass data to the getDeviceData function. +type FunctionData struct { + Api string `json:"api"` + App string `json:"app"` + Ct *resty.Client `json:"ct"` + Epoch int64 `json:"epoch"` + Limit int `json:"limit"` + Mac string `json:"mac"` +} + +// String is a helper function to print the FunctionData struct as a string. +func (f FunctionData) String() string { + r, _ := json.Marshal(f) + + return fmt.Sprint(string(r)) +} + +// NewFunctionData creates a new FunctionData object with some default values and return +// it to the caller as a pointer. +func (f FunctionData) NewFunctionData(client *resty.Client) *FunctionData { + return &FunctionData{ + Api: "", + App: "", + Ct: client, + Epoch: 0, + Limit: 1, + Mac: "", + } +} + +// DeviceDataResponse is used to marshal/unmarshal the response from the +// devices/macAddress endpoint. +type DeviceDataResponse []struct { + Baromabsin float64 `json:"baromabsin"` + Baromrelin float64 `json:"baromrelin"` + BattLightning int `json:"batt_lightning"` + Dailyrainin float64 `json:"dailyrainin"` + Date time.Time `json:"date"` + Dateutc int64 `json:"dateutc"` + DewPoint float64 `json:"dewPoint"` + DewPointin float64 `json:"dewPointin"` + Eventrainin float64 `json:"eventrainin"` + FeelsLike float64 `json:"feelsLike"` + FeelsLikein float64 `json:"feelsLikein"` + Hourlyrainin float64 `json:"hourlyrainin"` + Humidity int `json:"humidity"` + Humidityin int `json:"humidityin"` + LastRain time.Time `json:"lastRain"` + LightningDay int `json:"lightning_day"` + LightningDistance float64 `json:"lightning_distance"` + LightningHour int `json:"lightning_hour"` + LightningTime int64 `json:"lightning_time"` + Maxdailygust float64 `json:"maxdailygust"` + Monthlyrainin float64 `json:"monthlyrainin"` + Solarradiation float64 `json:"solarradiation"` + Tempf float64 `json:"tempf"` + Tempinf float64 `json:"tempinf"` + Tz string `json:"tz"` + Uv int `json:"uv"` + Weeklyrainin float64 `json:"weeklyrainin"` + Winddir int `json:"winddir"` + WinddirAvg10M int `json:"winddir_avg10m"` + Windgustmph float64 `json:"windgustmph"` + WindspdmphAvg10M float64 `json:"windspdmph_avg10m"` + Windspeedmph float64 `json:"windspeedmph"` + Yearlyrainin float64 `json:"yearlyrainin"` +} + +// String is a helper function to print the DeviceDataResponse struct as a string. +func (d DeviceDataResponse) String() string { + r, _ := json.Marshal(d) + + return fmt.Sprint(string(r)) +} + +// DeviceData is used to marshal/unmarshal the response from the +// 'devices' API endpoint. This should be removed, since this data is +// not captured. It's only possible use is for a quasi-real-time data pull. +type DeviceData struct { + Baromabsin float64 `json:"baromabsin"` + Baromrelin float64 `json:"baromrelin"` + BattLightning int `json:"batt_lightning"` + Dailyrainin int `json:"dailyrainin"` + Date time.Time `json:"date"` + Dateutc int64 `json:"dateutc"` + DewPoint float64 `json:"dewPoint"` + DewPointin float64 `json:"dewPointin"` + Eventrainin int `json:"eventrainin"` + FeelsLike float64 `json:"feelsLike"` + FeelsLikein float64 `json:"feelsLikein"` + Hourlyrainin int `json:"hourlyrainin"` + Humidity int `json:"humidity"` + Humidityin int `json:"humidityin"` + LastRain time.Time `json:"lastRain"` + LightningDay int `json:"lightning_day"` + LightningDistance float64 `json:"lightning_distance"` + LightningHour int `json:"lightning_hour"` + LightningTime int64 `json:"lightning_time"` + Maxdailygust float64 `json:"maxdailygust"` + Monthlyrainin float64 `json:"monthlyrainin"` + Solarradiation float64 `json:"solarradiation"` + Tempf float64 `json:"tempf"` + Tempinf float64 `json:"tempinf"` + Tz string `json:"tz"` + Uv int `json:"uv"` + Weeklyrainin float64 `json:"weeklyrainin"` + Winddir int `json:"winddir"` + WinddirAvg10M int `json:"winddir_avg10m"` + Windgustmph float64 `json:"windgustmph"` + WindspdmphAvg10M float64 `json:"windspdmph_avg10m"` + Windspeedmph float64 `json:"windspeedmph"` + Yearlyrainin float64 `json:"yearlyrainin"` +} + +// This info struct will likely be deleted at some point in the near future since it is +// never used. It is part of the AmbientDevice and coords structs. +type geo struct { + Coordinates []float64 `json:"coordinates"` + Type string `json:"type"` +} + +// This info struct will likely be deleted at some point in the near future since it is +// never used. It is part of the AmbientDevice and coords structs. +type specificCoords struct { + Lat float64 `json:"lat"` + Lon float64 `json:"lon"` +} + +// This info struct will likely be deleted at some point in the near future since it is +// never used. It is part of the AmbientDevice and info structs. +type coords struct { + Address string `json:"address"` + Coords specificCoords `json:"coords"` + Elevation float64 `json:"elevation"` + Geo geo `json:"geo"` + Location string `json:"location"` +} + +// This info struct will likely be deleted at some point in the near future since it is +// never used. It is part of the AmbientDevice struct. +type info struct { + Coords coords `json:"coords"` + Name string `json:"name"` +} + +// AmbientDevice is a struct that is used in the marshal/unmarshal JSON. This structure +// is not fully required, since all we use is the MacAddress field. The rest of the data +// is thrown away. +type AmbientDevice []struct { + Info info `json:"info"` + LastData DeviceData `json:"DeviceData"` + MacAddress string `json:"macAddress"` +} + +// String is a helper function to print the AmbientDevice struct as a string. +func (a AmbientDevice) String() string { + r, err := json.Marshal(a) + CheckReturn(err, "unable to marshall json from AmbientDevice", "warning") + + return fmt.Sprint(string(r)) +} diff --git a/client/data_structures_test.go b/client/data_structures_test.go new file mode 100644 index 0000000..67eb762 --- /dev/null +++ b/client/data_structures_test.go @@ -0,0 +1,85 @@ +package client + +import ( + "context" + "testing" + "time" + + "gopkg.in/resty.v1" +) + +func TestAmbientDevice_String(t *testing.T) { + tests := []struct { + name string + a AmbientDevice + want string + }{ + {}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.a.String(); got != tt.want { + t.Errorf("String() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestDeviceDataResponse_String(t *testing.T) { + tests := []struct { + name string + d DeviceDataResponse + want string + }{ + // TODO: Add test cases. + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.d.String(); got != tt.want { + t.Errorf("String() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestFunctionData_String(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), time.Minute) + defer cancel() + + epoch := time.Now().UnixMilli() + + client := createAwnClient() + + type fields struct { + Api string + App string + Ct *resty.Client + Cx context.Context + Epoch int64 + Limit int + Mac string + } + tests := []struct { + name string + fields fields + want string + }{ + {name: "FunctionDataString()", fields: {Api: "api", App: "app", Ct: createAwnClient(), Cx: ctx, Epoch: epoch, Limit: 100, Mac: "00:11:22:33:44:55"}, want: {}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f := FunctionData{ + Api: tt.fields.Api, + App: tt.fields.App, + Ct: tt.fields.Ct, + Cx: tt.fields.Cx, + Epoch: tt.fields.Epoch, + Limit: tt.fields.Limit, + Mac: tt.fields.Mac, + } + if got := f.String(); got != tt.want { + t.Errorf("String() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/client/realtime_client.go b/client/realtime_client.go new file mode 100644 index 0000000..9cc4016 --- /dev/null +++ b/client/realtime_client.go @@ -0,0 +1,39 @@ +// this is obviously a work in progress. + +package client + +const ( + // apiVersionRealtime is a string and describes the version of the real-time API. + apiVersionRealtime = "/api=1" + + // baseUrlRealtime The base URL for the Ambient Weather real-time API as a string. + baseURLRealtime = "wss://rt2.ambientweather.net" +) + +// GetRealtimeData is a public function that will connect to the Ambient Weather real-time +// weather API via Websockets and fetch live data. +func GetRealtimeData() (string, error) { + /* + https://ambientweather.docs.apiary.io/#reference/ambient-realtime-api + + setup: https://rt2.ambientweather.net/?api=1&applicationKey=AppKey + ctx, cancel := context.WithTimeout(context.Background(), time.Minute) + defer cancel() + + c, _, err := websocket.Dial(ctx, "ws://localhost:8080", nil) + if err != nil { + // ... + } + defer c.CloseNow() + + err = wsjson.Write(ctx, c, "hi") + if err != nil { + // ... + } + + c.Close(websocket.StatusNormalClosure, "") + + */ + _ := baseURLRealtime + apiVersionRealtime + return "", nil +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..84721db --- /dev/null +++ b/go.mod @@ -0,0 +1,7 @@ +module ambient-weather-client + +go 1.21 + +require gopkg.in/resty.v1 v1.12.0 + +require golang.org/x/net v0.0.0-20181220203305-927f97764cc3 // indirect diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..3815b15 --- /dev/null +++ b/go.sum @@ -0,0 +1,4 @@ +golang.org/x/net v0.0.0-20181220203305-927f97764cc3 h1:eH6Eip3UpmR+yM/qI9Ijluzb1bNv/cAU/n+6l8tRSis= +golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +gopkg.in/resty.v1 v1.12.0 h1:CuXP0Pjfw9rOuY6EP+UvtNvt5DSqHpIxILZKT/quCZI= +gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo=