mirror of
https://github.com/dnote/dnote
synced 2026-03-14 14:35:50 +01:00
627 lines
18 KiB
Go
627 lines
18 KiB
Go
/* Copyright 2025 Dnote Authors
|
|
*
|
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
* you may not use this file except in compliance with the License.
|
|
* You may obtain a copy of the License at
|
|
*
|
|
* http://www.apache.org/licenses/LICENSE-2.0
|
|
*
|
|
* Unless required by applicable law or agreed to in writing, software
|
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
* See the License for the specific language governing permissions and
|
|
* limitations under the License.
|
|
*/
|
|
|
|
// Package client provides interfaces for interacting with the Dnote server
|
|
// and the data structures for responses
|
|
package client
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/dnote/dnote/pkg/cli/context"
|
|
"github.com/dnote/dnote/pkg/cli/log"
|
|
"github.com/pkg/errors"
|
|
"golang.org/x/time/rate"
|
|
)
|
|
|
|
// ErrInvalidLogin is an error for invalid credentials for login
|
|
var ErrInvalidLogin = errors.New("wrong credentials")
|
|
|
|
// ErrContentTypeMismatch is an error for invalid credentials for login
|
|
var ErrContentTypeMismatch = errors.New("content type mismatch")
|
|
|
|
// HTTPError represents an HTTP error response from the server
|
|
type HTTPError struct {
|
|
StatusCode int
|
|
Message string
|
|
}
|
|
|
|
func (e *HTTPError) Error() string {
|
|
return fmt.Sprintf(`response %d "%s"`, e.StatusCode, e.Message)
|
|
}
|
|
|
|
// IsConflict returns true if the error is a 409 Conflict error
|
|
func (e *HTTPError) IsConflict() bool {
|
|
return e.StatusCode == 409
|
|
}
|
|
|
|
var contentTypeApplicationJSON = "application/json"
|
|
var contentTypeNone = ""
|
|
|
|
// requestOptions contains options for requests
|
|
type requestOptions struct {
|
|
HTTPClient *http.Client
|
|
// ExpectedContentType is the Content-Type that the client is expecting from the server
|
|
ExpectedContentType *string
|
|
}
|
|
|
|
const (
|
|
// clientRateLimitPerSecond is the max requests per second the client will make
|
|
clientRateLimitPerSecond = 50
|
|
// clientRateLimitBurst is the burst capacity for rate limiting
|
|
clientRateLimitBurst = 100
|
|
)
|
|
|
|
// rateLimitedTransport wraps an http.RoundTripper with rate limiting
|
|
type rateLimitedTransport struct {
|
|
transport http.RoundTripper
|
|
limiter *rate.Limiter
|
|
}
|
|
|
|
func (t *rateLimitedTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
|
// Wait for rate limiter to allow the request
|
|
if err := t.limiter.Wait(req.Context()); err != nil {
|
|
return nil, err
|
|
}
|
|
return t.transport.RoundTrip(req)
|
|
}
|
|
|
|
// NewRateLimitedHTTPClient creates an HTTP client with rate limiting
|
|
func NewRateLimitedHTTPClient() *http.Client {
|
|
// Calculate interval from rate: 1 second / requests per second
|
|
interval := time.Second / time.Duration(clientRateLimitPerSecond)
|
|
|
|
transport := &rateLimitedTransport{
|
|
transport: http.DefaultTransport,
|
|
limiter: rate.NewLimiter(rate.Every(interval), clientRateLimitBurst),
|
|
}
|
|
return &http.Client{
|
|
Transport: transport,
|
|
}
|
|
}
|
|
|
|
func getHTTPClient(ctx context.DnoteCtx, options *requestOptions) *http.Client {
|
|
if options != nil && options.HTTPClient != nil {
|
|
return options.HTTPClient
|
|
}
|
|
|
|
if ctx.HTTPClient != nil {
|
|
return ctx.HTTPClient
|
|
}
|
|
|
|
return &http.Client{}
|
|
}
|
|
|
|
func getExpectedContentType(options *requestOptions) string {
|
|
if options != nil && options.ExpectedContentType != nil {
|
|
return *options.ExpectedContentType
|
|
}
|
|
|
|
return contentTypeApplicationJSON
|
|
}
|
|
|
|
func getReq(ctx context.DnoteCtx, path, method, body string) (*http.Request, error) {
|
|
endpoint := fmt.Sprintf("%s%s", ctx.APIEndpoint, path)
|
|
req, err := http.NewRequest(method, endpoint, strings.NewReader(body))
|
|
if err != nil {
|
|
return nil, errors.Wrap(err, "constructing http request")
|
|
}
|
|
|
|
req.Header.Set("CLI-Version", ctx.Version)
|
|
|
|
if ctx.SessionKey != "" {
|
|
credential := fmt.Sprintf("Bearer %s", ctx.SessionKey)
|
|
req.Header.Set("Authorization", credential)
|
|
}
|
|
|
|
return req, nil
|
|
}
|
|
|
|
// checkRespErr checks if the given http response indicates an error. It returns a boolean indicating
|
|
// if the response is an error, and a decoded error message.
|
|
func checkRespErr(res *http.Response) error {
|
|
if res.StatusCode < 400 {
|
|
return nil
|
|
}
|
|
|
|
body, err := io.ReadAll(res.Body)
|
|
if err != nil {
|
|
return errors.Wrapf(err, "server responded with %d but client could not read the response body", res.StatusCode)
|
|
}
|
|
|
|
bodyStr := string(body)
|
|
return &HTTPError{
|
|
StatusCode: res.StatusCode,
|
|
Message: strings.TrimRight(bodyStr, "\n"),
|
|
}
|
|
}
|
|
|
|
func checkContentType(res *http.Response, options *requestOptions) error {
|
|
expected := getExpectedContentType(options)
|
|
|
|
got := res.Header.Get("Content-Type")
|
|
if got != expected {
|
|
return errors.Wrapf(ErrContentTypeMismatch, "got: '%s' want: '%s'. Did you configure your endpoint correctly?", got, expected)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// doReq does a http request to the given path in the api endpoint
|
|
func doReq(ctx context.DnoteCtx, method, path, body string, options *requestOptions) (*http.Response, error) {
|
|
req, err := getReq(ctx, path, method, body)
|
|
if err != nil {
|
|
return nil, errors.Wrap(err, "getting request")
|
|
}
|
|
|
|
log.Debug("HTTP %s %s\n", method, path)
|
|
|
|
hc := getHTTPClient(ctx, options)
|
|
res, err := hc.Do(req)
|
|
if err != nil {
|
|
return res, errors.Wrap(err, "making http request")
|
|
}
|
|
|
|
log.Debug("HTTP %d %s\n", res.StatusCode, res.Status)
|
|
|
|
if err = checkRespErr(res); err != nil {
|
|
return res, errors.Wrap(err, "server responded with an error")
|
|
}
|
|
|
|
if err = checkContentType(res, options); err != nil {
|
|
return res, errors.Wrap(err, "unexpected Content-Type")
|
|
}
|
|
|
|
return res, nil
|
|
}
|
|
|
|
// doAuthorizedReq does a http request to the given path in the api endpoint as a user,
|
|
// with the appropriate headers. The given path should include the preceding slash.
|
|
func doAuthorizedReq(ctx context.DnoteCtx, method, path, body string, options *requestOptions) (*http.Response, error) {
|
|
if ctx.SessionKey == "" {
|
|
return nil, errors.New("no session key found")
|
|
}
|
|
|
|
return doReq(ctx, method, path, body, options)
|
|
}
|
|
|
|
// GetSyncStateResp is the response get sync state endpoint
|
|
type GetSyncStateResp struct {
|
|
FullSyncBefore int `json:"full_sync_before"`
|
|
MaxUSN int `json:"max_usn"`
|
|
CurrentTime int64 `json:"current_time"`
|
|
}
|
|
|
|
// GetSyncState gets the sync state response from the server
|
|
func GetSyncState(ctx context.DnoteCtx) (GetSyncStateResp, error) {
|
|
var ret GetSyncStateResp
|
|
|
|
res, err := doAuthorizedReq(ctx, "GET", "/v3/sync/state", "", nil)
|
|
if err != nil {
|
|
return ret, errors.Wrap(err, "constructing http request")
|
|
}
|
|
|
|
body, err := io.ReadAll(res.Body)
|
|
if err != nil {
|
|
return ret, errors.Wrap(err, "reading the response body")
|
|
}
|
|
|
|
if err = json.Unmarshal(body, &ret); err != nil {
|
|
return ret, errors.Wrap(err, "unmarshalling the payload")
|
|
}
|
|
|
|
return ret, nil
|
|
}
|
|
|
|
// SyncFragNote represents a note in a sync fragment and contains only the necessary information
|
|
// for the client to sync the note locally
|
|
type SyncFragNote struct {
|
|
UUID string `json:"uuid"`
|
|
BookUUID string `json:"book_uuid"`
|
|
USN int `json:"usn"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
AddedOn int64 `json:"added_on"`
|
|
EditedOn int64 `json:"edited_on"`
|
|
Body string `json:"content"`
|
|
Deleted bool `json:"deleted"`
|
|
}
|
|
|
|
// SyncFragBook represents a book in a sync fragment and contains only the necessary information
|
|
// for the client to sync the note locally
|
|
type SyncFragBook struct {
|
|
UUID string `json:"uuid"`
|
|
USN int `json:"usn"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
AddedOn int64 `json:"added_on"`
|
|
Label string `json:"label"`
|
|
Deleted bool `json:"deleted"`
|
|
}
|
|
|
|
// SyncFragment contains a piece of information about the server's state.
|
|
type SyncFragment struct {
|
|
FragMaxUSN int `json:"frag_max_usn"`
|
|
UserMaxUSN int `json:"user_max_usn"`
|
|
CurrentTime int64 `json:"current_time"`
|
|
Notes []SyncFragNote `json:"notes"`
|
|
Books []SyncFragBook `json:"books"`
|
|
ExpungedNotes []string `json:"expunged_notes"`
|
|
ExpungedBooks []string `json:"expunged_books"`
|
|
}
|
|
|
|
// GetSyncFragmentResp is the response from the get sync fragment endpoint
|
|
type GetSyncFragmentResp struct {
|
|
Fragment SyncFragment `json:"fragment"`
|
|
}
|
|
|
|
// GetSyncFragment gets a sync fragment response from the server
|
|
func GetSyncFragment(ctx context.DnoteCtx, afterUSN int) (GetSyncFragmentResp, error) {
|
|
v := url.Values{}
|
|
v.Set("after_usn", strconv.Itoa(afterUSN))
|
|
queryStr := v.Encode()
|
|
|
|
path := fmt.Sprintf("/v3/sync/fragment?%s", queryStr)
|
|
res, err := doAuthorizedReq(ctx, "GET", path, "", nil)
|
|
if err != nil {
|
|
return GetSyncFragmentResp{}, errors.Wrap(err, "making the request")
|
|
}
|
|
|
|
body, err := io.ReadAll(res.Body)
|
|
if err != nil {
|
|
return GetSyncFragmentResp{}, errors.Wrap(err, "reading the response body")
|
|
}
|
|
|
|
var resp GetSyncFragmentResp
|
|
if err = json.Unmarshal(body, &resp); err != nil {
|
|
return resp, errors.Wrap(err, "unmarshalling the payload")
|
|
}
|
|
|
|
return resp, nil
|
|
}
|
|
|
|
// RespBook is the book in the response from the create book api
|
|
type RespBook struct {
|
|
ID int `json:"id"`
|
|
UUID string `json:"uuid"`
|
|
USN int `json:"usn"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
Label string `json:"label"`
|
|
}
|
|
|
|
// CreateBookPayload is a payload for creating a book
|
|
type CreateBookPayload struct {
|
|
Name string `json:"name"`
|
|
}
|
|
|
|
// CreateBookResp is the response from create book api
|
|
type CreateBookResp struct {
|
|
Book RespBook `json:"book"`
|
|
}
|
|
|
|
// CreateBook creates a new book in the server
|
|
func CreateBook(ctx context.DnoteCtx, label string) (CreateBookResp, error) {
|
|
payload := CreateBookPayload{
|
|
Name: label,
|
|
}
|
|
b, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return CreateBookResp{}, errors.Wrap(err, "marshaling payload")
|
|
}
|
|
|
|
res, err := doAuthorizedReq(ctx, "POST", "/v3/books", string(b), nil)
|
|
if err != nil {
|
|
return CreateBookResp{}, errors.Wrap(err, "posting a book to the server")
|
|
}
|
|
|
|
var resp CreateBookResp
|
|
if err := json.NewDecoder(res.Body).Decode(&resp); err != nil {
|
|
return resp, errors.Wrap(err, "decoding response payload")
|
|
}
|
|
|
|
return resp, nil
|
|
}
|
|
|
|
type updateBookPayload struct {
|
|
Name *string `json:"name"`
|
|
}
|
|
|
|
// UpdateBookResp is the response from create book api
|
|
type UpdateBookResp struct {
|
|
Book RespBook `json:"book"`
|
|
}
|
|
|
|
// UpdateBook updates a book in the server
|
|
func UpdateBook(ctx context.DnoteCtx, label, uuid string) (UpdateBookResp, error) {
|
|
payload := updateBookPayload{
|
|
Name: &label,
|
|
}
|
|
b, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return UpdateBookResp{}, errors.Wrap(err, "marshaling payload")
|
|
}
|
|
|
|
endpoint := fmt.Sprintf("/v3/books/%s", uuid)
|
|
res, err := doAuthorizedReq(ctx, "PATCH", endpoint, string(b), nil)
|
|
if err != nil {
|
|
return UpdateBookResp{}, errors.Wrap(err, "posting a book to the server")
|
|
}
|
|
|
|
var resp UpdateBookResp
|
|
if err := json.NewDecoder(res.Body).Decode(&resp); err != nil {
|
|
return resp, errors.Wrap(err, "decoding payload")
|
|
}
|
|
|
|
return resp, nil
|
|
}
|
|
|
|
// DeleteBookResp is the response from create book api
|
|
type DeleteBookResp struct {
|
|
Status int `json:"status"`
|
|
Book RespBook `json:"book"`
|
|
}
|
|
|
|
// DeleteBook deletes a book in the server
|
|
func DeleteBook(ctx context.DnoteCtx, uuid string) (DeleteBookResp, error) {
|
|
endpoint := fmt.Sprintf("/v3/books/%s", uuid)
|
|
res, err := doAuthorizedReq(ctx, "DELETE", endpoint, "", nil)
|
|
if err != nil {
|
|
return DeleteBookResp{}, errors.Wrap(err, "deleting a book in the server")
|
|
}
|
|
|
|
var resp DeleteBookResp
|
|
if err := json.NewDecoder(res.Body).Decode(&resp); err != nil {
|
|
return resp, errors.Wrap(err, "decoding the response")
|
|
}
|
|
|
|
return resp, nil
|
|
}
|
|
|
|
// CreateNotePayload is a payload for creating a note
|
|
type CreateNotePayload struct {
|
|
BookUUID string `json:"book_uuid"`
|
|
Body string `json:"content"`
|
|
}
|
|
|
|
// CreateNoteResp is the response from create note endpoint
|
|
type CreateNoteResp struct {
|
|
Result RespNote `json:"result"`
|
|
}
|
|
|
|
type respNoteBook struct {
|
|
UUID string `json:"uuid"`
|
|
Label string `json:"label"`
|
|
}
|
|
|
|
type respNoteUser struct {
|
|
Name string `json:"name"`
|
|
}
|
|
|
|
// RespNote is a note in the response
|
|
type RespNote struct {
|
|
UUID string `json:"uuid"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
Body string `json:"content"`
|
|
AddedOn int64 `json:"added_on"`
|
|
Public bool `json:"public"`
|
|
USN int `json:"usn"`
|
|
Book respNoteBook `json:"book"`
|
|
User respNoteUser `json:"user"`
|
|
}
|
|
|
|
// CreateNote creates a note in the server
|
|
func CreateNote(ctx context.DnoteCtx, bookUUID, content string) (CreateNoteResp, error) {
|
|
payload := CreateNotePayload{
|
|
BookUUID: bookUUID,
|
|
Body: content,
|
|
}
|
|
b, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return CreateNoteResp{}, errors.Wrap(err, "marshaling payload")
|
|
}
|
|
|
|
res, err := doAuthorizedReq(ctx, "POST", "/v3/notes", string(b), nil)
|
|
if err != nil {
|
|
return CreateNoteResp{}, errors.Wrap(err, "posting a book to the server")
|
|
}
|
|
|
|
var resp CreateNoteResp
|
|
if err := json.NewDecoder(res.Body).Decode(&resp); err != nil {
|
|
return CreateNoteResp{}, errors.Wrap(err, "decoding payload")
|
|
}
|
|
|
|
return resp, nil
|
|
}
|
|
|
|
type updateNotePayload struct {
|
|
BookUUID *string `json:"book_uuid"`
|
|
Body *string `json:"content"`
|
|
}
|
|
|
|
// UpdateNoteResp is the response from create book api
|
|
type UpdateNoteResp struct {
|
|
Status int `json:"status"`
|
|
Result RespNote `json:"result"`
|
|
}
|
|
|
|
// UpdateNote updates a note in the server
|
|
func UpdateNote(ctx context.DnoteCtx, uuid, bookUUID, content string) (UpdateNoteResp, error) {
|
|
payload := updateNotePayload{
|
|
BookUUID: &bookUUID,
|
|
Body: &content,
|
|
}
|
|
b, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return UpdateNoteResp{}, errors.Wrap(err, "marshaling payload")
|
|
}
|
|
|
|
endpoint := fmt.Sprintf("/v3/notes/%s", uuid)
|
|
res, err := doAuthorizedReq(ctx, "PATCH", endpoint, string(b), nil)
|
|
if err != nil {
|
|
return UpdateNoteResp{}, errors.Wrap(err, "patching a note to the server")
|
|
}
|
|
|
|
var resp UpdateNoteResp
|
|
if err := json.NewDecoder(res.Body).Decode(&resp); err != nil {
|
|
return UpdateNoteResp{}, errors.Wrap(err, "decoding payload")
|
|
}
|
|
|
|
return resp, nil
|
|
}
|
|
|
|
// DeleteNoteResp is the response from remove note api
|
|
type DeleteNoteResp struct {
|
|
Status int `json:"status"`
|
|
Result RespNote `json:"result"`
|
|
}
|
|
|
|
// DeleteNote removes a note in the server
|
|
func DeleteNote(ctx context.DnoteCtx, uuid string) (DeleteNoteResp, error) {
|
|
endpoint := fmt.Sprintf("/v3/notes/%s", uuid)
|
|
res, err := doAuthorizedReq(ctx, "DELETE", endpoint, "", nil)
|
|
if err != nil {
|
|
return DeleteNoteResp{}, errors.Wrap(err, "patching a note to the server")
|
|
}
|
|
|
|
var resp DeleteNoteResp
|
|
if err := json.NewDecoder(res.Body).Decode(&resp); err != nil {
|
|
return DeleteNoteResp{}, errors.Wrap(err, "decoding payload")
|
|
}
|
|
|
|
return resp, nil
|
|
}
|
|
|
|
// GetBooksResp is a response from get books endpoint
|
|
type GetBooksResp []struct {
|
|
UUID string `json:"uuid"`
|
|
Label string `json:"label"`
|
|
}
|
|
|
|
// GetBooks gets books from the server
|
|
func GetBooks(ctx context.DnoteCtx, sessionKey string) (GetBooksResp, error) {
|
|
res, err := doAuthorizedReq(ctx, "GET", "/v3/books", "", nil)
|
|
if err != nil {
|
|
return GetBooksResp{}, errors.Wrap(err, "making http request")
|
|
}
|
|
|
|
var resp GetBooksResp
|
|
if err := json.NewDecoder(res.Body).Decode(&resp); err != nil {
|
|
return GetBooksResp{}, errors.Wrap(err, "decoding payload")
|
|
}
|
|
|
|
return resp, nil
|
|
}
|
|
|
|
// PresigninResponse is a reponse from /v3/presignin endpoint
|
|
type PresigninResponse struct {
|
|
Iteration int `json:"iteration"`
|
|
}
|
|
|
|
// GetPresignin gets presignin credentials
|
|
func GetPresignin(ctx context.DnoteCtx, email string) (PresigninResponse, error) {
|
|
res, err := doReq(ctx, "GET", fmt.Sprintf("/v3/presignin?email=%s", email), "", nil)
|
|
if err != nil {
|
|
return PresigninResponse{}, errors.Wrap(err, "making http request")
|
|
}
|
|
|
|
var resp PresigninResponse
|
|
if err := json.NewDecoder(res.Body).Decode(&resp); err != nil {
|
|
return PresigninResponse{}, errors.Wrap(err, "decoding payload")
|
|
}
|
|
|
|
return resp, nil
|
|
}
|
|
|
|
// SigninPayload is a payload for /v3/signin
|
|
type SigninPayload struct {
|
|
Email string `json:"email"`
|
|
Passowrd string `json:"password"`
|
|
}
|
|
|
|
// SigninResponse is a response from /v3/signin endpoint
|
|
type SigninResponse struct {
|
|
Key string `json:"key"`
|
|
ExpiresAt int64 `json:"expires_at"`
|
|
}
|
|
|
|
// Signin requests a session token
|
|
func Signin(ctx context.DnoteCtx, email, password string) (SigninResponse, error) {
|
|
payload := SigninPayload{
|
|
Email: email,
|
|
Passowrd: password,
|
|
}
|
|
b, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return SigninResponse{}, errors.Wrap(err, "marshaling payload")
|
|
}
|
|
res, err := doReq(ctx, "POST", "/v3/signin", string(b), nil)
|
|
if err != nil {
|
|
// Check if this is a 401 Unauthorized error
|
|
var httpErr *HTTPError
|
|
if errors.As(err, &httpErr) && httpErr.StatusCode == http.StatusUnauthorized {
|
|
return SigninResponse{}, ErrInvalidLogin
|
|
}
|
|
return SigninResponse{}, errors.Wrap(err, "making http request")
|
|
}
|
|
|
|
var resp SigninResponse
|
|
if err := json.NewDecoder(res.Body).Decode(&resp); err != nil {
|
|
return SigninResponse{}, errors.Wrap(err, "decoding payload")
|
|
}
|
|
|
|
return resp, nil
|
|
}
|
|
|
|
// Signout deletes a user session on the server side
|
|
func Signout(ctx context.DnoteCtx, sessionKey string) error {
|
|
// Create a client that shares the transport (and thus rate limiter) from ctx.HTTPClient
|
|
// but doesn't follow redirects
|
|
var hc *http.Client
|
|
if ctx.HTTPClient != nil {
|
|
hc = &http.Client{
|
|
Transport: ctx.HTTPClient.Transport,
|
|
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
|
return http.ErrUseLastResponse
|
|
},
|
|
}
|
|
} else {
|
|
log.Warnf("No HTTP client configured for signout - falling back\n")
|
|
hc = &http.Client{
|
|
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
|
return http.ErrUseLastResponse
|
|
},
|
|
}
|
|
}
|
|
|
|
opts := requestOptions{
|
|
HTTPClient: hc,
|
|
ExpectedContentType: &contentTypeNone,
|
|
}
|
|
_, err := doAuthorizedReq(ctx, "POST", "/v3/signout", "", &opts)
|
|
if err != nil {
|
|
return errors.Wrap(err, "making http request")
|
|
}
|
|
|
|
return nil
|
|
}
|