Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ go get -u github.com/alexmerren/httpcache

Here's an example of using the `httpcache` module to cache responses:

<!-- TODO UPDATE THIS!!!! -->

```go
func main() {
// Create a new SQLite database to store HTTP responses.
Expand Down Expand Up @@ -69,3 +71,20 @@ func main() {
## ❓ Questions and Support

Any questions can be submitted via [GitHub Issues](https://www.github.com/alexmerren/httpcache/issues). Feel free to start contributing or asking any questions required!

## Roadmap

- Implement cache interface in separate cache modules:
- [x] Sqlite
- [ ] Duckdb
- [ ] Redis
- Make library thread safe to access cache.
- Implement other features from https://pypi.org/project/requests-cache/
- [ ] Cache-control header for expiration time on individual records.
- [ ] Match headers to save different response on header value.
- [ ] Stale-if-error to use stale response if request errors out.
- [ ] (maybe) ignored parameters so don't save API keys?
- Write full test suite!
- Write benchmark tests
- Write documentation and contribution guide

30 changes: 20 additions & 10 deletions cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,24 +12,34 @@ import (
type Cache interface {

// Save a response for a HTTP request using [context.Background].
Save(response *http.Response, expiryTime *time.Duration) error
Save(response *http.Response) error

// Read a saved response for a HTTP request using [context.Background].
Read(request *http.Request) (*http.Response, error)
Read(request *http.Request) (*ReadResult, error)

// Save a response for a HTTP request with a [context.Context]. expiryTime
// is the duration from [time.Now] to expire the response.
SaveContext(ctx context.Context, response *http.Response, expiryTime *time.Duration) error
// Delete a saved response using [context.Background].
Delete(response *http.Response) error

// Save a response for a HTTP request with a [context.Context].
SaveContext(ctx context.Context, response *http.Response) error

// Read a saved response for a HTTP request with a [context.Context]. If no
// response is saved for the corresponding request, or the expiryTime has
// been surpassed, then return [ErrNoResponse].
ReadContext(ctx context.Context, request *http.Request) (*http.Response, error)
// response is saved for the corresponding request.
ReadContext(ctx context.Context, request *http.Request) (*ReadResult, error)

// Delete a saved response with a [context.Context].
DeleteContext(ctx context.Context, response *http.Response) error
}

// ReadResult is the result of a successful read operation on the cache.
type ReadResult struct {
response *http.Response
createdAt *time.Time
}

var (
// ErrNoResponse describes when the cache does not have a response stored.
// ErrNoResult describes when the cache does not have a response stored.
// [Transport] will check if ErrNoResponse is returned from [Cache.Read]. If
// ErrNoResponse is returned, then the request/response will be saved with [Save].
ErrNoResponse = errors.New("no stored response")
ErrNoResult = errors.New("found no result from read")
)
7 changes: 7 additions & 0 deletions caches/duckdb/duckdb.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package duckdb

import "database/sql"

type DuckdbCache struct {
Database *sql.DB
}
3 changes: 3 additions & 0 deletions caches/duckdb/go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
module github.com/alexmerren/httpcache/caches/duckdb

go 1.22.3
16 changes: 16 additions & 0 deletions caches/sqlite/go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
module github.com/alexmerren/httpcache/caches/sqlite

go 1.22.3

require (
github.com/DATA-DOG/go-sqlmock v1.5.2
github.com/alexmerren/httpcache v0.4.0
github.com/mattn/go-sqlite3 v1.14.32
github.com/stretchr/testify v1.11.1
)

require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
17 changes: 17 additions & 0 deletions caches/sqlite/go.sum
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
github.com/alexmerren/httpcache v0.4.0 h1:NBVrDmJW7QFJ7xP2Qf9gV6+uiXVvVDBiGiCkcdbMbkU=
github.com/alexmerren/httpcache v0.4.0/go.mod h1:v8A6Vrn/8alCPq6577Ti5q/WerAQthkysMaPb9SJPsc=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/kisielk/sqlstruct v0.0.0-20201105191214-5f3e10d3ab46/go.mod h1:yyMNCyc/Ib3bDTKd379tNMpB/7/H5TjM2Y9QJ5THLbE=
github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs=
github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
52 changes: 21 additions & 31 deletions sqlite_cache.go → caches/sqlite/sqlite.go
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package httpcache
package sqlite

import (
"bytes"
Expand All @@ -11,6 +11,7 @@ import (
"path/filepath"
"time"

"github.com/alexmerren/httpcache"
_ "github.com/mattn/go-sqlite3"
)

Expand All @@ -21,19 +22,19 @@ const (
request_method TEXT NOT NULL,
response_body BLOB NOT NULL,
status_code INTEGER NOT NULL,
expiry_time INTEGER)`
created_at INTEGER NOT NULL)`

saveRequestQuery = `
INSERT OR REPLACE INTO responses (
request_url,
request_method,
response_body,
status_code,
expiry_time)
created_at)
VALUES (?, ?, ?, ?, ?)`

readRequestQuery = `
SELECT response_body, status_code, expiry_time
SELECT response_body, status_code, created_at
FROM responses
WHERE request_url = ? AND request_method = ?`
)
Expand All @@ -42,16 +43,16 @@ const (
// constructed correctly.
var ErrNoDatabase = errors.New("no database connection")

// SqliteCache is a default implementation of [Cache] which creates a local
// SQLite cache to persist and to query HTTP responses.
type SqliteCache struct {
// SqliteDriver is a default implementation of [Cache] which creates a local
// SQLite driver to persist and to query HTTP responses.
type SqliteDriver struct {
Database *sql.DB
}

// NewSqliteCache creates a new SQLite database with a certain name. This name
// NewSqliteDriver creates a new SQLite database with a certain name. This name
// is the filename of the database. If the file does not exist, then we create
// it. If the file is in a non-existent directory, we create the directory.
func NewSqliteCache(databaseName string) (*SqliteCache, error) {
func NewSqliteDriver(databaseName string) (*SqliteDriver, error) {
fileExists, err := doesFileExist(databaseName)
if err != nil {
return nil, err
Expand All @@ -74,36 +75,29 @@ func NewSqliteCache(databaseName string) (*SqliteCache, error) {
return nil, err
}

return &SqliteCache{
return &SqliteDriver{
Database: conn,
}, nil
}

func (s *SqliteCache) Save(response *http.Response, expiryTime *time.Duration) error {
return s.SaveContext(context.Background(), response, expiryTime)
func (s *SqliteDriver) Save(response *http.Response) error {
return s.SaveContext(context.Background(), response)
}

func (s *SqliteCache) Read(request *http.Request) (*http.Response, error) {
func (s *SqliteDriver) Read(request *http.Request) (*http.Response, error) {
return s.ReadContext(context.Background(), request)
}

func (s *SqliteCache) SaveContext(ctx context.Context, response *http.Response, expiryTime *time.Duration) error {
func (s *SqliteDriver) SaveContext(ctx context.Context, response *httpcache.ReadResult) error {
responseBody := bytes.NewBuffer(nil)
_, err := io.Copy(responseBody, response.Body)
if err != nil {
return err
}

var expiryTimestamp *int64
if expiryTime != nil {
calculatedExpiry := time.Now().Add(*expiryTime).Unix()
expiryTimestamp = &calculatedExpiry
} else {
expiryTimestamp = nil
}

// Reset the response body stream to the beginning to be read again.
response.Body = io.NopCloser(responseBody)
now := time.Now().UnixMilli()

requestUrl := generateUrl(response.Request)
_, err = s.Database.Exec(
Expand All @@ -112,7 +106,7 @@ func (s *SqliteCache) SaveContext(ctx context.Context, response *http.Response,
response.Request.Method,
responseBody.Bytes(),
response.StatusCode,
expiryTimestamp,
now,
)
if err != nil {
return err
Expand All @@ -121,7 +115,7 @@ func (s *SqliteCache) SaveContext(ctx context.Context, response *http.Response,
return nil
}

func (s *SqliteCache) ReadContext(ctx context.Context, request *http.Request) (*http.Response, error) {
func (s *SqliteDriver) ReadContext(ctx context.Context, request *http.Request) (*http.Response, error) {
if s.Database == nil {
return nil, ErrNoDatabase
}
Expand All @@ -131,20 +125,16 @@ func (s *SqliteCache) ReadContext(ctx context.Context, request *http.Request) (*

var responseBody []byte
var responseStatusCode int
var expiryTime *int64
var createdAt *int64

err := row.Scan(&responseBody, &responseStatusCode, &expiryTime)
err := row.Scan(&responseBody, &responseStatusCode, &createdAt)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNoResponse
return nil, httpcache.ErrNoResult
}
return nil, err
}

if expiryTime != nil && time.Now().Unix() > *expiryTime {
return nil, ErrNoResponse
}

return &http.Response{
Request: request,
Body: io.NopCloser(bytes.NewReader(responseBody)),
Expand Down
32 changes: 8 additions & 24 deletions config.go
Original file line number Diff line number Diff line change
@@ -1,43 +1,27 @@
package httpcache

import (
"net/http"
"time"
)

var (
defaultAllowedStatusCodes = []int{http.StatusOK}
defaultAllowedMethods = []string{http.MethodGet}
defaultExpiryTime = time.Duration(60*24*7) * time.Minute
)

// DefaultConfig creates a [Config] with default values, namely:
// - AllowedStatusCodes: [http.StatusOK]
// - AllowedMethods: [http.MethodGet]
// - ExpiryTime: 7 days.
var DefaultConfig = NewConfigBuilder().
WithAllowedStatusCodes(defaultAllowedStatusCodes).
WithAllowedMethods(defaultAllowedMethods).
WithExpiryTime(defaultExpiryTime).
Build()
// DefaultConfig creates a [Config] with a configuration of:
// - Saves responses regardless of response status code, or HTTP request method;
// - Saved responses never expire.
var DefaultConfig = NewConfigBuilder().Build()

// Config describes the configuration to use when saving and reading responses
// from [Cache] using the [Transport].
// Config describes the configuration to use when saving to, and reading responses
// from the [Cache].
type Config struct {

// AllowedStatusCodes describes if a HTTP response should be saved by
// checking that it's status code is accepted by [Cache]. If the HTTP
// response's status code is not in AllowedStatusCodes, then do not persist.
//
// This is a required field.
AllowedStatusCodes []int
AllowedStatusCodes *[]int

// AllowedMethods describes if a HTTP response should be saved by checking
// if the HTTP request's method is accepted by the [Cache]. If the HTTP
// request's method is not in AllowedMethods, then do not persist.
//
// This is a required field.
AllowedMethods []string
AllowedMethods *[]string

// ExpiryTime describes when a HTTP response should be considered invalid.
ExpiryTime *time.Duration
Expand Down
10 changes: 3 additions & 7 deletions config_builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,10 @@ type configBuilder struct {

// allowedStatusCodes only persists HTTP responses that have an appropriate
// status code (i.e. 200).
//
// This is a required field.
allowedStatusCodes *[]int

// allowedMethods only persists HTTP responses that use an appropriate HTTP
// method (i.e. "GET").
//
// This is a required field.
allowedMethods *[]string

// expiryTime invalidates HTTP responses after a duration has elapsed from
Expand All @@ -47,12 +43,12 @@ func (c *configBuilder) WithExpiryTime(expiryDuration time.Duration) *configBuil
return c
}

// Build constructs a [Config] that is ready to be consumed by [Transport]. If
// Build constructs a [Config] that is ready to be consumed by [Cache]. If
// the configuration passed by [configBuilder] is invalid, it will panic.
func (c *configBuilder) Build() *Config {
return &Config{
AllowedStatusCodes: *c.allowedStatusCodes,
AllowedMethods: *c.allowedMethods,
AllowedStatusCodes: c.allowedStatusCodes,
AllowedMethods: c.allowedMethods,
ExpiryTime: c.expiryTime,
}
}
Loading
Loading