amo/api/api.go
2024-03-04 06:31:33 +05:00

384 lines
8.1 KiB
Go

package api
import (
"encoding/json"
"io/ioutil"
"net/http"
"net/url"
"errors"
"bytes"
"time"
"fmt"
"log"
"io"
"os"
)
const (
DefaultContentType = "application/json"
DefaultAccept = DefaultContentType
DefaultCacheControl = "no-cache"
)
type ClientOptions struct {
Url string `json:"url"`
RedirectUrl string `json:"redirect_url"`
AuthCode string `json:"auth_code"`
ClientId string `json:"client_id"`
ClientSecret string `json:"client_secret"`
AccessToken string `json:"access_token"`
ExpirationAt time.Time `json:"expiration_at,omitempty"`
RefreshToken string `json:"refresh_token"`
}
type Client struct {
*log.Logger
options *ClientOptions
BaseUrl *url.URL
secretStoreFilePath string
Debug bool
}
type requestOptions struct {
HttpMethod string
Body interface{}
Headers map[string]string
Abs bool
}
type OauthTokenResponse struct {
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"`
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
}
type TokenPair struct {
Access, Refresh string
}
func NewApi(secretPath string) (*Client, error) {
client := &Client{
Logger: log.New(os.Stdout, "AmoCRM client: ", log.Ldate | log.Ltime),
secretStoreFilePath: secretPath,
}
options, err := client.readSecret()
if err != nil {
return nil, NewApiError(err)
}
if client.Debug {
client.Printf("ClientOptions: %v\n", options)
}
if options.Url == "" || options.RedirectUrl == "" {
return nil, NewApiError(InvalidUrlOptionsErr)
}
resolvedUrl, err := url.Parse(options.Url)
if err != nil {
return nil, NewApiError(err)
}
client.BaseUrl = resolvedUrl
client.options = options
var (
//pair *TokenPair
exchangeErr error
exchanged bool
)
if client.options.AccessToken == "" && client.options.RefreshToken == "" {
if client.options.ClientSecret == "" ||
client.options.ClientId == "" ||
client.options.AuthCode == "" {
return nil, NewApiError(InvalidExchangeAuthOptionsErr)
}
_, exchangeErr = client.ExchangeAuth()
exchanged = true
}
if (!exchanged || exchangeErr != nil) && options.RefreshToken != "" {
// Refreshing token before the work.
// Should think of how often should refresh
// the token. (see the ExpiresIn)
_, err = client.RefreshToken()
if err != nil {
return nil, NewApiError(err)
}
}
return client, nil
}
func (api *Client) readSecret() (*ClientOptions, error) {
f, err := os.Open(api.secretStoreFilePath)
if err != nil {
return nil, err
}
bts, err := io.ReadAll(f)
if err != nil {
return nil, err
}
ret := ClientOptions{}
err = json.Unmarshal(bts, &ret)
if err != nil {
return nil, err
}
return &ret, nil
}
func (api *Client) writeSecret() error {
bts, err := json.MarshalIndent(api.options, "", "\t")
if err != nil {
return err
}
err = os.WriteFile(api.secretStoreFilePath, bts, 0644)
if err != nil {
return err
}
return err
}
func (api *Client) rdoRequest(resourceUrl string, requestParams requestOptions, result interface{}) error {
var (
requestUrl *url.URL
err error
)
if !requestParams.Abs {
resolvedUrl, err := url.Parse(resourceUrl)
if err != nil {
return err
}
requestUrl = api.BaseUrl.ResolveReference(resolvedUrl)
} else {
requestUrl, err = url.Parse(resourceUrl)
if err != nil {
return err
}
}
requestBody := new(bytes.Buffer)
if requestParams.Body != nil {
encoder := json.NewEncoder(requestBody)
if err := encoder.Encode(requestParams.Body); err != nil {
return err
}
}
request, err := http.NewRequest(requestParams.HttpMethod, requestUrl.String(), requestBody)
if err != nil {
return err
}
if requestParams.Headers != nil {
for k, v := range requestParams.Headers {
request.Header.Set(k, v)
}
}
response, err := http.DefaultClient.Do(request)
if api.Debug {
api.Printf("Request: %+v\n\nAmo response: %+v\n\n", request, response)
}
if response == nil {
return RequestError(NoInternetErr)
}
if err != nil {
return RequestError(fmt.Errorf(
"%w: %d %s %s",
err,
response.StatusCode,
requestParams.HttpMethod,
resourceUrl,
))
}
defer response.Body.Close()
if response.StatusCode == 204 {
return RequestError(NoContentErr)
}
if response.StatusCode >= 400 {
var specErr error
if response.StatusCode == 401 {
specErr = NoAuthErr
}
responseErrorInfo, _ := ioutil.ReadAll(response.Body)
return RequestError(fmt.Errorf(
"%w: %s %s %q %d",
specErr,
requestParams.HttpMethod,
resourceUrl,
string(responseErrorInfo),
response.StatusCode,
))
}
if result != nil {
decoder := json.NewDecoder(response.Body)
err = decoder.Decode(result)
if err != nil {
return err
}
}
return nil
}
func (api *Client) doRequest(
resourceUrl string, requestParams requestOptions, result any,
) error {
var err error
err = api.rdoRequest(resourceUrl, requestParams, result)
if errors.Is(err, NoAuthErr) && api.options.RefreshToken != "" {
_, err = api.RefreshToken()
if err != nil {
return nil
}
return api.rdoRequest(resourceUrl, requestParams, result)
} else if err != nil {
return err
}
return nil
}
func (api *Client) ExchangeAuth() (*TokenPair, error) {
result := &OauthTokenResponse{}
request := map[string] string {
"client_id": api.options.ClientId,
"client_secret": api.options.ClientSecret,
"grant_type": "authorization_code",
"code": api.options.AuthCode,
"redirect_uri": api.options.RedirectUrl,
}
err := api.rdoRequest("/oauth2/access_token", requestOptions{
HttpMethod: http.MethodPost,
Body: request,
Headers: getHeaders(""),
}, result)
if err != nil {
return nil, err
}
ret := &TokenPair{
Access: result.AccessToken,
Refresh: result.RefreshToken,
}
api.options.AccessToken = result.AccessToken
api.options.RefreshToken = result.RefreshToken
now := time.Now()
api.options.ExpirationAt = now.Add(
time.Second*time.Duration(result.ExpiresIn),
)
err = api.writeSecret()
if err != nil {
return nil, err
}
return ret, nil
}
func (api *Client) RefreshTokenIfExpired() error {
if api.options.RefreshToken == "" {
return nil
}
now := time.Now()
if now.After(api.options.ExpirationAt) || now.Equal(api.options.ExpirationAt){
_, err := api.RefreshToken()
if err != nil {
return err
}
}
return nil
}
func (api *Client) RefreshToken() (*OauthTokenResponse, error) {
result := new(OauthTokenResponse)
request := map[string]string{
"client_id": api.options.ClientId,
"client_secret": api.options.ClientSecret,
"grant_type": "refresh_token",
"refresh_token": api.options.RefreshToken,
"redirect_uri": api.options.RedirectUrl,
}
err := api.rdoRequest("/oauth2/access_token", requestOptions{
HttpMethod: http.MethodPost,
Body: request,
Headers: getHeaders(""),
}, result)
if err != nil {
return nil, err
}
api.options.AccessToken = result.AccessToken
api.options.RefreshToken = result.RefreshToken
now := time.Now()
api.options.ExpirationAt = now.Add(
time.Second*time.Duration(result.ExpiresIn),
)
err = api.writeSecret()
if err != nil {
return nil, err
}
return result, nil
}
func (api *Client) Get(resource string, result interface{}, abs ...bool) error {
var a bool
if len(abs) > 0 {
a = abs[0]
}
return api.doRequest(resource, requestOptions{
HttpMethod: http.MethodGet,
Body: nil,
Headers: getHeaders(api.options.AccessToken),
Abs: a,
}, result)
}
func (api *Client) Post(resource string, request interface{}, result interface{}) error {
return api.doRequest(resource, requestOptions{
HttpMethod: http.MethodPost,
Body: request,
Headers: getHeaders(api.options.AccessToken),
}, result)
}
func (api *Client) Patch(resource string, request interface{}, result interface{}) error {
return api.doRequest(resource, requestOptions{
HttpMethod: http.MethodPatch,
Body: request,
Headers: getHeaders(api.options.AccessToken),
}, result)
}
func getHeaders(token string) map[string]string {
headers := map[string]string{
"Accept": DefaultAccept,
"Cache-Control": DefaultCacheControl,
"Content-Type": DefaultContentType,
}
if len(token) > 0 {
headers["Authorization"] = fmt.Sprintf("Bearer %s", token)
}
return headers
}