golift.io/starr@v1.0.0/interface.go (about)

     1  package starr
     2  
     3  import (
     4  	"bufio"
     5  	"bytes"
     6  	"context"
     7  	"encoding/json"
     8  	"errors"
     9  	"fmt"
    10  	"io"
    11  	"net/http"
    12  	"net/http/cookiejar"
    13  	"net/url"
    14  	"strings"
    15  
    16  	"golang.org/x/net/publicsuffix"
    17  )
    18  
    19  // APIer is used by the sub packages to allow mocking the http methods in tests.
    20  // It changes once in a while, so avoid making hard dependencies on it.
    21  type APIer interface {
    22  	GetInitializeJS(ctx context.Context) (*InitializeJS, error)
    23  	// Login is used for non-API paths, like downloading backups or the initialize.js file.
    24  	Login(ctx context.Context) error
    25  	// Normal data, returns response. Do not use these in starr app methods.
    26  	// These methods are generally for non-api paths and will not ensure an /api uri prefix.
    27  	Get(ctx context.Context, req Request) (*http.Response, error)    // Get request; Params are optional.
    28  	Post(ctx context.Context, req Request) (*http.Response, error)   // Post request; Params should contain io.Reader.
    29  	Put(ctx context.Context, req Request) (*http.Response, error)    // Put request; Params should contain io.Reader.
    30  	Delete(ctx context.Context, req Request) (*http.Response, error) // Delete request; Params are optional.
    31  	// Normal data, unmarshals into provided interface. Use these because they close the response body.
    32  	GetInto(ctx context.Context, req Request, output interface{}) error  // API GET Request.
    33  	PostInto(ctx context.Context, req Request, output interface{}) error // API POST Request.
    34  	PutInto(ctx context.Context, req Request, output interface{}) error  // API PUT Request.
    35  	DeleteAny(ctx context.Context, req Request) error                    // API Delete request.
    36  }
    37  
    38  // Config must satisfy the APIer struct.
    39  var _ APIer = (*Config)(nil)
    40  
    41  // InitializeJS is the data contained in the initialize.js file.
    42  type InitializeJS struct {
    43  	App          string
    44  	APIRoot      string
    45  	APIKey       string
    46  	Release      string
    47  	Version      string
    48  	InstanceName string
    49  	Theme        string
    50  	Branch       string
    51  	Analytics    string
    52  	UserHash     string
    53  	URLBase      string
    54  	IsProduction bool
    55  }
    56  
    57  // Login POSTs to the login form in a Starr app and saves the authentication cookie for future use.
    58  func (c *Config) Login(ctx context.Context) error {
    59  	if c.Client.Jar == nil {
    60  		jar, err := cookiejar.New(&cookiejar.Options{PublicSuffixList: publicsuffix.List})
    61  		if err != nil {
    62  			return fmt.Errorf("cookiejar.New(publicsuffix): %w", err)
    63  		}
    64  
    65  		c.Client.Jar = jar
    66  	}
    67  
    68  	params := make(url.Values)
    69  	params.Add("username", c.Username)
    70  	params.Add("password", c.Password)
    71  
    72  	req := Request{URI: "/login", Body: bytes.NewBufferString(params.Encode())}
    73  	codeErr := &ReqError{}
    74  
    75  	resp, err := c.req(ctx, http.MethodPost, req)
    76  	if err != nil {
    77  		if !errors.As(err, &codeErr) { // pointer to a pointer, yup.
    78  			return fmt.Errorf("invalid reply authenticating as user '%s': %w", c.Username, err)
    79  		}
    80  	} else {
    81  		// Protect a nil map in case we don't get an error (which should be impossible).
    82  		codeErr.Header = resp.Header
    83  	}
    84  
    85  	closeResp(resp)
    86  
    87  	if u, _ := url.Parse(c.URL); strings.Contains(codeErr.Get("location"), "loginFailed") ||
    88  		len(c.Client.Jar.Cookies(u)) == 0 {
    89  		return fmt.Errorf("%w: authenticating as user '%s' failed", ErrRequestError, c.Username)
    90  	}
    91  
    92  	c.cookie = true
    93  
    94  	return nil
    95  }
    96  
    97  // Get makes a GET http request and returns the body.
    98  func (c *Config) Get(ctx context.Context, req Request) (*http.Response, error) {
    99  	return c.Req(ctx, http.MethodGet, req)
   100  }
   101  
   102  // Post makes a POST http request and returns the body.
   103  func (c *Config) Post(ctx context.Context, req Request) (*http.Response, error) {
   104  	return c.Req(ctx, http.MethodPost, req)
   105  }
   106  
   107  // Put makes a PUT http request and returns the body.
   108  func (c *Config) Put(ctx context.Context, req Request) (*http.Response, error) {
   109  	return c.Req(ctx, http.MethodPut, req)
   110  }
   111  
   112  // Delete makes a DELETE http request and returns the body.
   113  func (c *Config) Delete(ctx context.Context, req Request) (*http.Response, error) {
   114  	return c.Req(ctx, http.MethodDelete, req)
   115  }
   116  
   117  // GetInto performs an HTTP GET against an API path and
   118  // unmarshals the payload into the provided pointer interface.
   119  func (c *Config) GetInto(ctx context.Context, req Request, output interface{}) error {
   120  	resp, err := c.api(ctx, http.MethodGet, req)
   121  	return decode(output, resp, err)
   122  }
   123  
   124  // PostInto performs an HTTP POST against an API path and
   125  // unmarshals the payload into the provided pointer interface.
   126  func (c *Config) PostInto(ctx context.Context, req Request, output interface{}) error {
   127  	resp, err := c.api(ctx, http.MethodPost, req)
   128  	return decode(output, resp, err)
   129  }
   130  
   131  // PutInto performs an HTTP PUT against an API path and
   132  // unmarshals the payload into the provided pointer interface.
   133  func (c *Config) PutInto(ctx context.Context, req Request, output interface{}) error {
   134  	resp, err := c.api(ctx, http.MethodPut, req)
   135  	return decode(output, resp, err)
   136  }
   137  
   138  // DeleteAny performs an HTTP DELETE against an API path, output is ignored.
   139  func (c *Config) DeleteAny(ctx context.Context, req Request) error {
   140  	resp, err := c.api(ctx, http.MethodDelete, req)
   141  	closeResp(resp)
   142  
   143  	return err
   144  }
   145  
   146  // decode is an extra procedure to check an error and decode the JSON resp.Body payload.
   147  func decode(output interface{}, resp *http.Response, err error) error {
   148  	if err != nil {
   149  		return err
   150  	} else if output == nil {
   151  		closeResp(resp) // read the body and close it.
   152  		return fmt.Errorf("this is a Starr library bug: %w", ErrNilInterface)
   153  	}
   154  
   155  	defer resp.Body.Close()
   156  
   157  	if err = json.NewDecoder(resp.Body).Decode(output); err != nil {
   158  		return fmt.Errorf("decoding Starr JSON response body: %w", err)
   159  	}
   160  
   161  	return nil
   162  }
   163  
   164  // GetInitializeJS returns the data from the initialize.js file.
   165  // If the instance requires authentication, you must call Login() before this method.
   166  func (c *Config) GetInitializeJS(ctx context.Context) (*InitializeJS, error) {
   167  	req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.URL+"initialize.js", nil)
   168  	if err != nil {
   169  		return nil, fmt.Errorf("http.NewRequestWithContext(initialize.js): %w", err)
   170  	}
   171  
   172  	resp, err := c.Client.Do(req)
   173  	if err != nil {
   174  		return nil, fmt.Errorf("httpClient.Do(req): %w", err)
   175  	}
   176  	defer resp.Body.Close()
   177  
   178  	if resp.StatusCode != http.StatusOK {
   179  		_, _ = io.Copy(io.Discard, resp.Body)
   180  		return nil, &ReqError{Code: resp.StatusCode}
   181  	}
   182  
   183  	return readInitializeJS(resp.Body)
   184  }
   185  
   186  func readInitializeJS(input io.Reader) (*InitializeJS, error) { //nolint:cyclop
   187  	output := &InitializeJS{}
   188  	scanner := bufio.NewScanner(input)
   189  
   190  	for scanner.Scan() {
   191  		switch split := strings.Fields(scanner.Text()); {
   192  		case len(split) < 2: //nolint:gomnd
   193  			continue
   194  		case split[0] == "apiRoot:":
   195  			output.APIRoot = strings.Trim(split[1], `"',`)
   196  		case split[0] == "apiKey:":
   197  			output.APIKey = strings.Trim(split[1], `"',`)
   198  		case split[0] == "version:":
   199  			output.Version = strings.Trim(split[1], `"',`)
   200  		case split[0] == "release:":
   201  			output.Release = strings.Trim(split[1], `"',`)
   202  		case split[0] == "instanceName:":
   203  			output.InstanceName = strings.Trim(split[1], `"',`)
   204  		case split[0] == "theme:":
   205  			output.Theme = strings.Trim(split[1], `"',`)
   206  		case split[0] == "branch:":
   207  			output.Branch = strings.Trim(split[1], `"',`)
   208  		case split[0] == "analytics:":
   209  			output.Analytics = strings.Trim(split[1], `"',`)
   210  		case split[0] == "userHash:":
   211  			output.UserHash = strings.Trim(split[1], `"',`)
   212  		case split[0] == "urlBase:":
   213  			output.URLBase = strings.Trim(split[1], `"',`)
   214  		case split[0] == "isProduction:":
   215  			output.IsProduction = strings.Trim(split[1], `"',`) == "true"
   216  		case strings.HasPrefix(split[0], "window."):
   217  			output.App = strings.TrimPrefix(split[0], "window.")
   218  		}
   219  	}
   220  
   221  	if err := scanner.Err(); err != nil {
   222  		return output, fmt.Errorf("scanning HTTP response: %w", err)
   223  	}
   224  
   225  	return output, nil
   226  }