Skip to content
Open
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
164 changes: 164 additions & 0 deletions v2/samples/http/authtoken/authtoken_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
package authtoken_test

import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"time"

"github.com/IBM/fp-go/v2/samples/http/authtoken"
)

func fakeAuthServerWithToken(t *testing.T, token string, expiresIn int) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
fmt.Fprintf(w, `{"access_token": "%s", "expires_in": %d, "token_type": "Bearer"}`, token, expiresIn)
}))
}

func fakeAuthServerError(t *testing.T, statusCode int, body string) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(statusCode)
w.Write([]byte(body))
}))
}

func TestGetToken_RefreshesWhenCacheEmpty(t *testing.T) {
server := fakeAuthServerWithToken(t, "fresh-token", 3600)
defer server.Close()

fixedTime := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
cache := authtoken.MakeTokenCache()()
env := authtoken.MakeEnv(server.URL, "test-api-key", server.Client(), cache, func() time.Time { return fixedTime })

ctx := context.Background()
token, err := authtoken.GetToken(env)(ctx)

if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if token.Token != "fresh-token" {
t.Errorf("expected token 'fresh-token', got '%s'", token.Token)
}
}

func TestGetToken_UsesCachedToken(t *testing.T) {
callCount := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
callCount++
w.Header().Set("Content-Type", "application/json")
fmt.Fprintf(w, `{"access_token": "token-%d", "expires_in": 3600, "token_type": "Bearer"}`, callCount)
}))
defer server.Close()

fixedTime := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
cache := authtoken.MakeTokenCache()()
env := authtoken.MakeEnv(server.URL, "test-api-key", server.Client(), cache, func() time.Time { return fixedTime })

ctx := context.Background()

// First call - should hit server
token1, _ := authtoken.GetToken(env)(ctx)
// Second call - should use cache
token2, _ := authtoken.GetToken(env)(ctx)

if callCount != 1 {
t.Errorf("expected 1 server call, got %d", callCount)
}
if token1.Token != token2.Token {
t.Errorf("expected same token, got '%s' and '%s'", token1.Token, token2.Token)
}
}

func TestGetToken_RefreshesExpiredToken(t *testing.T) {
callCount := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
callCount++
w.Header().Set("Content-Type", "application/json")
fmt.Fprintf(w, `{"access_token": "token-%d", "expires_in": 3600, "token_type": "Bearer"}`, callCount)
}))
defer server.Close()

currentTime := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
cache := authtoken.MakeTokenCache()()
env := authtoken.MakeEnv(server.URL, "test-api-key", server.Client(), cache, func() time.Time { return currentTime })

ctx := context.Background()

// First call
token1, _ := authtoken.GetToken(env)(ctx)

// Advance time past expiration
currentTime = currentTime.Add(2 * time.Hour)
env = authtoken.MakeEnv(server.URL, "test-api-key", server.Client(), cache, func() time.Time { return currentTime })

// Second call - should refresh
token2, _ := authtoken.GetToken(env)(ctx)

if callCount != 2 {
t.Errorf("expected 2 server calls, got %d", callCount)
}
if token1.Token == token2.Token {
t.Errorf("expected different tokens after expiration")
}
}

func TestGetToken_HandlesServerError(t *testing.T) {
server := fakeAuthServerError(t, http.StatusUnauthorized, "invalid credentials")
defer server.Close()

fixedTime := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
cache := authtoken.MakeTokenCache()()
env := authtoken.MakeEnv(server.URL, "bad-api-key", server.Client(), cache, func() time.Time { return fixedTime })

ctx := context.Background()
_, err := authtoken.GetToken(env)(ctx)

if err == nil {
t.Fatal("expected error for unauthorized request")
}
}

func TestGetTokenString_ReturnsTokenValue(t *testing.T) {
server := fakeAuthServerWithToken(t, "my-access-token", 3600)
defer server.Close()

fixedTime := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
cache := authtoken.MakeTokenCache()()
env := authtoken.MakeEnv(server.URL, "test-api-key", server.Client(), cache, func() time.Time { return fixedTime })

ctx := context.Background()
tokenStr, err := authtoken.GetTokenString(env)(ctx)

if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if tokenStr != "my-access-token" {
t.Errorf("expected 'my-access-token', got '%s'", tokenStr)
}
}

func TestGetAuthorizationHeader_ReturnsBearerFormat(t *testing.T) {
server := fakeAuthServerWithToken(t, "my-token", 3600)
defer server.Close()

fixedTime := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
cache := authtoken.MakeTokenCache()()
env := authtoken.MakeEnv(server.URL, "test-api-key", server.Client(), cache, func() time.Time { return fixedTime })

ctx := context.Background()
header, err := authtoken.GetAuthorizationHeader(env)(ctx)

if err != nil {
t.Fatalf("unexpected error: %v", err)
}
expected := "Bearer my-token"
if header != expected {
t.Errorf("expected '%s', got '%s'", expected, header)
}
}
114 changes: 114 additions & 0 deletions v2/samples/http/authtoken/cache.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
package authtoken

import (
"time"

F "github.com/IBM/fp-go/v2/function"
IO "github.com/IBM/fp-go/v2/io"
IORef "github.com/IBM/fp-go/v2/ioref"
O "github.com/IBM/fp-go/v2/option"
)

// Cache is a generic thread-safe cache that stores an optional value using IORef.
type Cache[T any] struct {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You do not need to wrap IORef into the cache, you can use it directly and define Cache just as a type alias.

I think the content of IORef should be Result[T] because it's possible that the resolution of the token resulted in an error

ref IORef.IORef[Option[T]]
}

// MakeCache creates a new empty cache for type T.
func MakeCache[T any]() IO.IO[*Cache[T]] {
return F.Pipe1(
IORef.MakeIORef(O.None[T]()),
IO.Map(func(ref IORef.IORef[Option[T]]) *Cache[T] {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This extra indirection is not needed

return &Cache[T]{ref: ref}
}),
)
}

// Get retrieves the currently cached value, if any.
func (c *Cache[T]) Get() IO.IO[Option[T]] {
return IORef.Read(c.ref)
}

// Set stores a new value in the cache, replacing any existing value.
func (c *Cache[T]) Set(value T) IO.IO[T] {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is where a race condition can occur, because when using Set in the service, you first read, validate, refresh and then set. But all of these operations need to be coordinated across callers.

You want to use IORef.ModifyIOK instead.

return F.Pipe2(
c.ref,
IORef.Write(O.Some(value)),
IO.Map(func(opt Option[T]) T {
return value
}),
)
}

// Clear removes the cached value, setting the cache back to None.
func (c *Cache[T]) Clear() IO.IO[Option[T]] {
return IORef.Write(O.None[T]())(c.ref)
}

// GetIf retrieves the cached value only if it satisfies the predicate.
func (c *Cache[T]) GetIf(predicate func(T) bool) IO.IO[Option[T]] {
return F.Pipe1(
c.Get(),
IO.Map(O.Filter(predicate)),
)
}

// Update applies a transformation function to the cached value.
func (c *Cache[T]) Update(f func(T) T) IO.IO[Option[T]] {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From a signature perspective you could write f Endomorphism[T] just for clarity (or leave it as is).

An update with an endomorphism makes sense if the update operation is a pure function. But in this case it's an effectful function, so you want to use ModifyIOK

return IORef.Modify(O.Map(f))(c.ref)
}

// TokenCache is a specialized cache for CachedToken with token-specific methods.
type TokenCache struct {
cache *Cache[CachedToken]
}

// MakeTokenCache creates a new empty token cache.
func MakeTokenCache() IO.IO[*TokenCache] {
return F.Pipe1(
MakeCache[CachedToken](),
IO.Map(func(cache *Cache[CachedToken]) *TokenCache {
return &TokenCache{cache: cache}
}),
)
}

// Get retrieves the currently cached token, if any.
func (c *TokenCache) Get() IO.IO[Option[CachedToken]] {
return c.cache.Get()
}

// Set stores a new token in the cache, replacing any existing token.
func (c *TokenCache) Set(token CachedToken) IO.IO[CachedToken] {
return c.cache.Set(token)
}

// Clear removes the cached token, setting the cache back to None.
func (c *TokenCache) Clear() IO.IO[Option[CachedToken]] {
return c.cache.Clear()
}

// IsExpired checks if the cached token is expired based on the current time.
func (c *TokenCache) IsExpired(currentTime time.Time) IO.IO[bool] {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • instead of using IO.IO[bool] as a response value consider using IO[Option[CachedToken]]. Reason: what would you do with a false return value? In this case the token is not expired, so you want to return the token from the cache. This needs another access to the cache in addition to the one that validated the token. This's not necessary if you return the unexpired token directly (and it avoids a race condition) (pattern: "Boolean Blindness)
  • is the signature IsExpired(currentTime time.Time) IO.IO[bool] really intended? The resulting IO is an operation that is executed at some point in the future, there is no correlation that it is executed in temporal proximity of the currentTime. This might make sense but from the term IsExpired I would expect that the IO operation tests whether or not the operation is expired at the point in time when it is invoked. In this case a better signature would be IsExpired(currentTime IO[time.Time]) IO[bool] (actually IsExpired(currentTime IO[time.Time]) IO[Option[CachedToken]]. This signature is basically IO.Kleisli[time.Time, CachedToken]

return F.Pipe1(
c.Get(),
IO.Map(O.Fold(
F.Constant(true),
func(token CachedToken) bool {
return !currentTime.Before(token.ExpiresAt)
},
)),
)
}

// GetIfValid retrieves the cached token only if it's not expired.
func (c *TokenCache) GetIfValid(currentTime time.Time) IO.IO[Option[CachedToken]] {
return c.cache.GetIf(func(token CachedToken) bool {
return currentTime.Before(token.ExpiresAt)
})
}

// Update applies a transformation function to the cached token.
func (c *TokenCache) Update(f func(CachedToken) CachedToken) IO.IO[Option[CachedToken]] {
return c.cache.Update(f)
}
Loading