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 }