mirror of
https://github.com/IBM/fp-go.git
synced 2025-11-23 22:14:53 +02:00
fix: implement some useful prisms
Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>
This commit is contained in:
@@ -403,5 +403,3 @@ func TestSlicePropertyBased(t *testing.T) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Made with Bob
|
|
||||||
|
|||||||
@@ -13,6 +13,36 @@
|
|||||||
// See the License for the specific language governing permissions and
|
// See the License for the specific language governing permissions and
|
||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
|
|
||||||
|
// Package builder provides utilities for building HTTP requests in a functional way
|
||||||
|
// using the ReaderIOEither monad. It integrates with the http/builder package to
|
||||||
|
// create composable, type-safe HTTP request builders with proper error handling
|
||||||
|
// and context support.
|
||||||
|
//
|
||||||
|
// The main function, Requester, converts a Builder from the http/builder package
|
||||||
|
// into a ReaderIOEither that produces HTTP requests. This allows for:
|
||||||
|
// - Immutable request building with method chaining
|
||||||
|
// - Automatic header management including Content-Length
|
||||||
|
// - Support for requests with and without bodies
|
||||||
|
// - Proper error handling wrapped in Either
|
||||||
|
// - Context propagation for cancellation and timeouts
|
||||||
|
//
|
||||||
|
// Example usage:
|
||||||
|
//
|
||||||
|
// import (
|
||||||
|
// "context"
|
||||||
|
// B "github.com/IBM/fp-go/v2/http/builder"
|
||||||
|
// RB "github.com/IBM/fp-go/v2/context/readerioeither/http/builder"
|
||||||
|
// )
|
||||||
|
//
|
||||||
|
// builder := F.Pipe3(
|
||||||
|
// B.Default,
|
||||||
|
// B.WithURL("https://api.example.com/users"),
|
||||||
|
// B.WithMethod("POST"),
|
||||||
|
// B.WithJSONBody(userData),
|
||||||
|
// )
|
||||||
|
//
|
||||||
|
// requester := RB.Requester(builder)
|
||||||
|
// result := requester(context.Background())()
|
||||||
package builder
|
package builder
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -31,6 +61,59 @@ import (
|
|||||||
O "github.com/IBM/fp-go/v2/option"
|
O "github.com/IBM/fp-go/v2/option"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Requester converts an http/builder.Builder into a ReaderIOEither that produces HTTP requests.
|
||||||
|
// It handles both requests with and without bodies, automatically managing headers including
|
||||||
|
// Content-Length for requests with bodies.
|
||||||
|
//
|
||||||
|
// The function performs the following operations:
|
||||||
|
// 1. Extracts the request body (if present) from the builder
|
||||||
|
// 2. Creates appropriate request constructor (with or without body)
|
||||||
|
// 3. Applies the target URL from the builder
|
||||||
|
// 4. Applies the HTTP method from the builder
|
||||||
|
// 5. Merges headers from the builder into the request
|
||||||
|
// 6. Handles any errors that occur during request construction
|
||||||
|
//
|
||||||
|
// For requests with a body:
|
||||||
|
// - Sets the Content-Length header automatically
|
||||||
|
// - Uses bytes.NewReader to create the request body
|
||||||
|
// - Merges builder headers into the request
|
||||||
|
//
|
||||||
|
// For requests without a body:
|
||||||
|
// - Creates a request with nil body
|
||||||
|
// - Merges builder headers into the request
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - builder: A pointer to an http/builder.Builder containing request configuration
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - A Requester (ReaderIOEither[*http.Request]) that, when executed with a context,
|
||||||
|
// produces either an error or a configured *http.Request
|
||||||
|
//
|
||||||
|
// Example with body:
|
||||||
|
//
|
||||||
|
// import (
|
||||||
|
// B "github.com/IBM/fp-go/v2/http/builder"
|
||||||
|
// RB "github.com/IBM/fp-go/v2/context/readerioeither/http/builder"
|
||||||
|
// )
|
||||||
|
//
|
||||||
|
// builder := F.Pipe3(
|
||||||
|
// B.Default,
|
||||||
|
// B.WithURL("https://api.example.com/users"),
|
||||||
|
// B.WithMethod("POST"),
|
||||||
|
// B.WithJSONBody(map[string]string{"name": "John"}),
|
||||||
|
// )
|
||||||
|
// requester := RB.Requester(builder)
|
||||||
|
// result := requester(context.Background())()
|
||||||
|
//
|
||||||
|
// Example without body:
|
||||||
|
//
|
||||||
|
// builder := F.Pipe2(
|
||||||
|
// B.Default,
|
||||||
|
// B.WithURL("https://api.example.com/users"),
|
||||||
|
// B.WithMethod("GET"),
|
||||||
|
// )
|
||||||
|
// requester := RB.Requester(builder)
|
||||||
|
// result := requester(context.Background())()
|
||||||
func Requester(builder *R.Builder) RIOEH.Requester {
|
func Requester(builder *R.Builder) RIOEH.Requester {
|
||||||
|
|
||||||
withBody := F.Curry3(func(data []byte, url string, method string) RIOE.ReaderIOEither[*http.Request] {
|
withBody := F.Curry3(func(data []byte, url string, method string) RIOE.ReaderIOEither[*http.Request] {
|
||||||
|
|||||||
@@ -57,3 +57,231 @@ func TestBuilderWithQuery(t *testing.T) {
|
|||||||
|
|
||||||
assert.True(t, E.IsRight(req(context.Background())()))
|
assert.True(t, E.IsRight(req(context.Background())()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestBuilderWithoutBody tests creating a request without a body
|
||||||
|
func TestBuilderWithoutBody(t *testing.T) {
|
||||||
|
builder := F.Pipe2(
|
||||||
|
R.Default,
|
||||||
|
R.WithURL("https://api.example.com/users"),
|
||||||
|
R.WithMethod("GET"),
|
||||||
|
)
|
||||||
|
|
||||||
|
requester := Requester(builder)
|
||||||
|
result := requester(context.Background())()
|
||||||
|
|
||||||
|
assert.True(t, E.IsRight(result), "Expected Right result")
|
||||||
|
|
||||||
|
req := E.GetOrElse(func(error) *http.Request { return nil })(result)
|
||||||
|
assert.NotNil(t, req, "Expected non-nil request")
|
||||||
|
assert.Equal(t, "GET", req.Method)
|
||||||
|
assert.Equal(t, "https://api.example.com/users", req.URL.String())
|
||||||
|
assert.Nil(t, req.Body, "Expected nil body for GET request")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBuilderWithBody tests creating a request with a body
|
||||||
|
func TestBuilderWithBody(t *testing.T) {
|
||||||
|
bodyData := []byte(`{"name":"John","age":30}`)
|
||||||
|
|
||||||
|
builder := F.Pipe3(
|
||||||
|
R.Default,
|
||||||
|
R.WithURL("https://api.example.com/users"),
|
||||||
|
R.WithMethod("POST"),
|
||||||
|
R.WithBytes(bodyData),
|
||||||
|
)
|
||||||
|
|
||||||
|
requester := Requester(builder)
|
||||||
|
result := requester(context.Background())()
|
||||||
|
|
||||||
|
assert.True(t, E.IsRight(result), "Expected Right result")
|
||||||
|
|
||||||
|
req := E.GetOrElse(func(error) *http.Request { return nil })(result)
|
||||||
|
assert.NotNil(t, req, "Expected non-nil request")
|
||||||
|
assert.Equal(t, "POST", req.Method)
|
||||||
|
assert.Equal(t, "https://api.example.com/users", req.URL.String())
|
||||||
|
assert.NotNil(t, req.Body, "Expected non-nil body for POST request")
|
||||||
|
assert.Equal(t, "24", req.Header.Get("Content-Length"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBuilderWithHeaders tests that headers are properly set
|
||||||
|
func TestBuilderWithHeaders(t *testing.T) {
|
||||||
|
builder := F.Pipe3(
|
||||||
|
R.Default,
|
||||||
|
R.WithURL("https://api.example.com/data"),
|
||||||
|
R.WithHeader("Authorization")("Bearer token123"),
|
||||||
|
R.WithHeader("Accept")("application/json"),
|
||||||
|
)
|
||||||
|
|
||||||
|
requester := Requester(builder)
|
||||||
|
result := requester(context.Background())()
|
||||||
|
|
||||||
|
assert.True(t, E.IsRight(result), "Expected Right result")
|
||||||
|
|
||||||
|
req := E.GetOrElse(func(error) *http.Request { return nil })(result)
|
||||||
|
assert.NotNil(t, req, "Expected non-nil request")
|
||||||
|
assert.Equal(t, "Bearer token123", req.Header.Get("Authorization"))
|
||||||
|
assert.Equal(t, "application/json", req.Header.Get("Accept"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBuilderWithInvalidURL tests error handling for invalid URLs
|
||||||
|
func TestBuilderWithInvalidURL(t *testing.T) {
|
||||||
|
builder := F.Pipe1(
|
||||||
|
R.Default,
|
||||||
|
R.WithURL("://invalid-url"),
|
||||||
|
)
|
||||||
|
|
||||||
|
requester := Requester(builder)
|
||||||
|
result := requester(context.Background())()
|
||||||
|
|
||||||
|
assert.True(t, E.IsLeft(result), "Expected Left result for invalid URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBuilderWithEmptyMethod tests creating a request with empty method
|
||||||
|
func TestBuilderWithEmptyMethod(t *testing.T) {
|
||||||
|
builder := F.Pipe2(
|
||||||
|
R.Default,
|
||||||
|
R.WithURL("https://api.example.com/users"),
|
||||||
|
R.WithMethod(""),
|
||||||
|
)
|
||||||
|
|
||||||
|
requester := Requester(builder)
|
||||||
|
result := requester(context.Background())()
|
||||||
|
|
||||||
|
// Empty method should still work (defaults to GET in http.NewRequest)
|
||||||
|
assert.True(t, E.IsRight(result), "Expected Right result")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBuilderWithMultipleHeaders tests setting multiple headers
|
||||||
|
func TestBuilderWithMultipleHeaders(t *testing.T) {
|
||||||
|
builder := F.Pipe4(
|
||||||
|
R.Default,
|
||||||
|
R.WithURL("https://api.example.com/data"),
|
||||||
|
R.WithHeader("X-Custom-Header-1")("value1"),
|
||||||
|
R.WithHeader("X-Custom-Header-2")("value2"),
|
||||||
|
R.WithHeader("X-Custom-Header-3")("value3"),
|
||||||
|
)
|
||||||
|
|
||||||
|
requester := Requester(builder)
|
||||||
|
result := requester(context.Background())()
|
||||||
|
|
||||||
|
assert.True(t, E.IsRight(result), "Expected Right result")
|
||||||
|
|
||||||
|
req := E.GetOrElse(func(error) *http.Request { return nil })(result)
|
||||||
|
assert.NotNil(t, req, "Expected non-nil request")
|
||||||
|
assert.Equal(t, "value1", req.Header.Get("X-Custom-Header-1"))
|
||||||
|
assert.Equal(t, "value2", req.Header.Get("X-Custom-Header-2"))
|
||||||
|
assert.Equal(t, "value3", req.Header.Get("X-Custom-Header-3"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBuilderWithBodyAndHeaders tests combining body and headers
|
||||||
|
func TestBuilderWithBodyAndHeaders(t *testing.T) {
|
||||||
|
bodyData := []byte(`{"test":"data"}`)
|
||||||
|
|
||||||
|
builder := F.Pipe4(
|
||||||
|
R.Default,
|
||||||
|
R.WithURL("https://api.example.com/submit"),
|
||||||
|
R.WithMethod("PUT"),
|
||||||
|
R.WithBytes(bodyData),
|
||||||
|
R.WithHeader("X-Request-ID")("12345"),
|
||||||
|
)
|
||||||
|
|
||||||
|
requester := Requester(builder)
|
||||||
|
result := requester(context.Background())()
|
||||||
|
|
||||||
|
assert.True(t, E.IsRight(result), "Expected Right result")
|
||||||
|
|
||||||
|
req := E.GetOrElse(func(error) *http.Request { return nil })(result)
|
||||||
|
assert.NotNil(t, req, "Expected non-nil request")
|
||||||
|
assert.Equal(t, "PUT", req.Method)
|
||||||
|
assert.NotNil(t, req.Body, "Expected non-nil body")
|
||||||
|
assert.Equal(t, "12345", req.Header.Get("X-Request-ID"))
|
||||||
|
assert.Equal(t, "15", req.Header.Get("Content-Length"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBuilderContextCancellation tests that context cancellation is respected
|
||||||
|
func TestBuilderContextCancellation(t *testing.T) {
|
||||||
|
builder := F.Pipe1(
|
||||||
|
R.Default,
|
||||||
|
R.WithURL("https://api.example.com/users"),
|
||||||
|
)
|
||||||
|
|
||||||
|
requester := Requester(builder)
|
||||||
|
|
||||||
|
// Create a cancelled context
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel() // Cancel immediately
|
||||||
|
|
||||||
|
result := requester(ctx)()
|
||||||
|
|
||||||
|
// The request should still be created (cancellation affects execution, not creation)
|
||||||
|
// But we verify the context is properly passed
|
||||||
|
req := E.GetOrElse(func(error) *http.Request { return nil })(result)
|
||||||
|
if req != nil {
|
||||||
|
assert.Equal(t, ctx, req.Context(), "Expected context to be set in request")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBuilderWithDifferentMethods tests various HTTP methods
|
||||||
|
func TestBuilderWithDifferentMethods(t *testing.T) {
|
||||||
|
methods := []string{"GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"}
|
||||||
|
|
||||||
|
for _, method := range methods {
|
||||||
|
t.Run(method, func(t *testing.T) {
|
||||||
|
builder := F.Pipe2(
|
||||||
|
R.Default,
|
||||||
|
R.WithURL("https://api.example.com/resource"),
|
||||||
|
R.WithMethod(method),
|
||||||
|
)
|
||||||
|
|
||||||
|
requester := Requester(builder)
|
||||||
|
result := requester(context.Background())()
|
||||||
|
|
||||||
|
assert.True(t, E.IsRight(result), "Expected Right result for method %s", method)
|
||||||
|
|
||||||
|
req := E.GetOrElse(func(error) *http.Request { return nil })(result)
|
||||||
|
assert.NotNil(t, req, "Expected non-nil request for method %s", method)
|
||||||
|
assert.Equal(t, method, req.Method)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBuilderWithJSON tests creating a request with JSON body
|
||||||
|
func TestBuilderWithJSON(t *testing.T) {
|
||||||
|
data := map[string]string{"username": "testuser", "email": "test@example.com"}
|
||||||
|
|
||||||
|
builder := F.Pipe3(
|
||||||
|
R.Default,
|
||||||
|
R.WithURL("https://api.example.com/v1/users"),
|
||||||
|
R.WithMethod("POST"),
|
||||||
|
R.WithJSON(data),
|
||||||
|
)
|
||||||
|
|
||||||
|
requester := Requester(builder)
|
||||||
|
result := requester(context.Background())()
|
||||||
|
|
||||||
|
assert.True(t, E.IsRight(result), "Expected Right result")
|
||||||
|
|
||||||
|
req := E.GetOrElse(func(error) *http.Request { return nil })(result)
|
||||||
|
assert.NotNil(t, req, "Expected non-nil request")
|
||||||
|
assert.Equal(t, "POST", req.Method)
|
||||||
|
assert.Equal(t, "https://api.example.com/v1/users", req.URL.String())
|
||||||
|
assert.Equal(t, "application/json", req.Header.Get("Content-Type"))
|
||||||
|
assert.NotNil(t, req.Body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBuilderWithBearer tests adding Bearer token
|
||||||
|
func TestBuilderWithBearer(t *testing.T) {
|
||||||
|
builder := F.Pipe2(
|
||||||
|
R.Default,
|
||||||
|
R.WithURL("https://api.example.com/protected"),
|
||||||
|
R.WithBearer("my-secret-token"),
|
||||||
|
)
|
||||||
|
|
||||||
|
requester := Requester(builder)
|
||||||
|
result := requester(context.Background())()
|
||||||
|
|
||||||
|
assert.True(t, E.IsRight(result), "Expected Right result")
|
||||||
|
|
||||||
|
req := E.GetOrElse(func(error) *http.Request { return nil })(result)
|
||||||
|
assert.NotNil(t, req, "Expected non-nil request")
|
||||||
|
assert.Equal(t, "Bearer my-secret-token", req.Header.Get("Authorization"))
|
||||||
|
}
|
||||||
|
|||||||
15
v2/context/readerioeither/http/builder/coverage.out
Normal file
15
v2/context/readerioeither/http/builder/coverage.out
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
mode: set
|
||||||
|
github.com/IBM/fp-go/v2/context/readerioeither/http/builder/builder.go:117.52,119.103 1 1
|
||||||
|
github.com/IBM/fp-go/v2/context/readerioeither/http/builder/builder.go:119.103,120.80 1 1
|
||||||
|
github.com/IBM/fp-go/v2/context/readerioeither/http/builder/builder.go:120.80,121.41 1 1
|
||||||
|
github.com/IBM/fp-go/v2/context/readerioeither/http/builder/builder.go:121.41,123.19 2 1
|
||||||
|
github.com/IBM/fp-go/v2/context/readerioeither/http/builder/builder.go:123.19,126.6 2 1
|
||||||
|
github.com/IBM/fp-go/v2/context/readerioeither/http/builder/builder.go:127.5,127.20 1 1
|
||||||
|
github.com/IBM/fp-go/v2/context/readerioeither/http/builder/builder.go:132.2,132.93 1 1
|
||||||
|
github.com/IBM/fp-go/v2/context/readerioeither/http/builder/builder.go:132.93,133.80 1 1
|
||||||
|
github.com/IBM/fp-go/v2/context/readerioeither/http/builder/builder.go:133.80,134.41 1 1
|
||||||
|
github.com/IBM/fp-go/v2/context/readerioeither/http/builder/builder.go:134.41,136.19 2 1
|
||||||
|
github.com/IBM/fp-go/v2/context/readerioeither/http/builder/builder.go:136.19,138.6 1 1
|
||||||
|
github.com/IBM/fp-go/v2/context/readerioeither/http/builder/builder.go:139.5,139.20 1 1
|
||||||
|
github.com/IBM/fp-go/v2/context/readerioeither/http/builder/builder.go:144.2,150.50 1 1
|
||||||
|
github.com/IBM/fp-go/v2/context/readerioeither/http/builder/builder.go:150.50,153.4 2 1
|
||||||
11
v2/context/readerioeither/http/coverage.out
Normal file
11
v2/context/readerioeither/http/coverage.out
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
mode: set
|
||||||
|
github.com/IBM/fp-go/v2/context/readerioeither/http/request.go:111.76,116.2 1 1
|
||||||
|
github.com/IBM/fp-go/v2/context/readerioeither/http/request.go:134.49,136.2 1 1
|
||||||
|
github.com/IBM/fp-go/v2/context/readerioeither/http/request.go:161.90,162.65 1 1
|
||||||
|
github.com/IBM/fp-go/v2/context/readerioeither/http/request.go:162.65,166.76 1 1
|
||||||
|
github.com/IBM/fp-go/v2/context/readerioeither/http/request.go:166.76,176.5 1 1
|
||||||
|
github.com/IBM/fp-go/v2/context/readerioeither/http/request.go:198.73,203.2 1 1
|
||||||
|
github.com/IBM/fp-go/v2/context/readerioeither/http/request.go:222.74,227.2 1 1
|
||||||
|
github.com/IBM/fp-go/v2/context/readerioeither/http/request.go:234.76,236.2 1 1
|
||||||
|
github.com/IBM/fp-go/v2/context/readerioeither/http/request.go:245.74,254.2 1 1
|
||||||
|
github.com/IBM/fp-go/v2/context/readerioeither/http/request.go:281.76,286.2 1 1
|
||||||
@@ -13,6 +13,22 @@
|
|||||||
// See the License for the specific language governing permissions and
|
// See the License for the specific language governing permissions and
|
||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
|
|
||||||
|
// Package http provides functional HTTP client utilities built on top of ReaderIOEither monad.
|
||||||
|
// It offers a composable way to make HTTP requests with context support, error handling,
|
||||||
|
// and response parsing capabilities. The package follows functional programming principles
|
||||||
|
// to ensure type-safe, testable, and maintainable HTTP operations.
|
||||||
|
//
|
||||||
|
// The main abstractions include:
|
||||||
|
// - Requester: A reader that constructs HTTP requests with context
|
||||||
|
// - Client: An interface for executing HTTP requests
|
||||||
|
// - Response readers: Functions to parse responses as bytes, text, or JSON
|
||||||
|
//
|
||||||
|
// Example usage:
|
||||||
|
//
|
||||||
|
// client := MakeClient(http.DefaultClient)
|
||||||
|
// request := MakeGetRequest("https://api.example.com/data")
|
||||||
|
// result := ReadJSON[MyType](client)(request)
|
||||||
|
// response := result(context.Background())()
|
||||||
package http
|
package http
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -30,14 +46,31 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type (
|
type (
|
||||||
// Requester is a reader that constructs a request
|
// Requester is a reader that constructs an HTTP request with context support.
|
||||||
|
// It represents a computation that, given a context, produces either an error
|
||||||
|
// or an HTTP request. This allows for composable request building with proper
|
||||||
|
// error handling and context propagation.
|
||||||
Requester = RIOE.ReaderIOEither[*http.Request]
|
Requester = RIOE.ReaderIOEither[*http.Request]
|
||||||
|
|
||||||
|
// Client is an interface for executing HTTP requests in a functional way.
|
||||||
|
// It wraps the standard http.Client and provides a Do method that works
|
||||||
|
// with the ReaderIOEither monad for composable, type-safe HTTP operations.
|
||||||
Client interface {
|
Client interface {
|
||||||
// Do can send an HTTP request considering a context
|
// Do executes an HTTP request and returns the response wrapped in a ReaderIOEither.
|
||||||
|
// It takes a Requester (which builds the request) and returns a computation that,
|
||||||
|
// when executed with a context, performs the HTTP request and returns either
|
||||||
|
// an error or the HTTP response.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - req: A Requester that builds the HTTP request
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - A ReaderIOEither that produces either an error or an *http.Response
|
||||||
Do(Requester) RIOE.ReaderIOEither[*http.Response]
|
Do(Requester) RIOE.ReaderIOEither[*http.Response]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// client is the internal implementation of the Client interface.
|
||||||
|
// It wraps a standard http.Client and provides functional HTTP operations.
|
||||||
client struct {
|
client struct {
|
||||||
delegate *http.Client
|
delegate *http.Client
|
||||||
doIOE func(*http.Request) IOE.IOEither[error, *http.Response]
|
doIOE func(*http.Request) IOE.IOEither[error, *http.Response]
|
||||||
@@ -45,11 +78,33 @@ type (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
// MakeRequest is an eitherized version of [http.NewRequestWithContext]
|
// MakeRequest is an eitherized version of http.NewRequestWithContext.
|
||||||
|
// It creates a Requester that builds an HTTP request with the given method, URL, and body.
|
||||||
|
// This function properly handles errors and wraps them in the Either monad.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - method: HTTP method (GET, POST, PUT, DELETE, etc.)
|
||||||
|
// - url: The target URL for the request
|
||||||
|
// - body: Optional request body (can be nil)
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - A Requester that produces either an error or an *http.Request
|
||||||
MakeRequest = RIOE.Eitherize3(http.NewRequestWithContext)
|
MakeRequest = RIOE.Eitherize3(http.NewRequestWithContext)
|
||||||
|
|
||||||
|
// makeRequest is a partially applied version of MakeRequest with the context parameter bound.
|
||||||
makeRequest = F.Bind13of3(MakeRequest)
|
makeRequest = F.Bind13of3(MakeRequest)
|
||||||
|
|
||||||
// specialize
|
// MakeGetRequest creates a GET request for the specified URL.
|
||||||
|
// It's a convenience function that specializes MakeRequest for GET requests with no body.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - url: The target URL for the GET request
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - A Requester that produces either an error or an *http.Request
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
// req := MakeGetRequest("https://api.example.com/users")
|
||||||
MakeGetRequest = makeRequest("GET", nil)
|
MakeGetRequest = makeRequest("GET", nil)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -60,12 +115,49 @@ func (client client) Do(req Requester) RIOE.ReaderIOEither[*http.Response] {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MakeClient creates an HTTP client proxy
|
// MakeClient creates a functional HTTP client wrapper around a standard http.Client.
|
||||||
|
// The returned Client provides methods for executing HTTP requests in a functional,
|
||||||
|
// composable way using the ReaderIOEither monad.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - httpClient: A standard *http.Client to wrap (e.g., http.DefaultClient)
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - A Client that can execute HTTP requests functionally
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// client := MakeClient(http.DefaultClient)
|
||||||
|
// // or with custom client
|
||||||
|
// customClient := &http.Client{Timeout: 10 * time.Second}
|
||||||
|
// client := MakeClient(customClient)
|
||||||
func MakeClient(httpClient *http.Client) Client {
|
func MakeClient(httpClient *http.Client) Client {
|
||||||
return client{delegate: httpClient, doIOE: IOE.Eitherize1(httpClient.Do)}
|
return client{delegate: httpClient, doIOE: IOE.Eitherize1(httpClient.Do)}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReadFullResponse sends a request, reads the response as a byte array and represents the result as a tuple
|
// ReadFullResponse sends an HTTP request, reads the complete response body as a byte array,
|
||||||
|
// and returns both the response and body as a tuple (FullResponse).
|
||||||
|
// It validates the HTTP status code and handles errors appropriately.
|
||||||
|
//
|
||||||
|
// The function performs the following steps:
|
||||||
|
// 1. Executes the HTTP request using the provided client
|
||||||
|
// 2. Validates the response status code (checks for HTTP errors)
|
||||||
|
// 3. Reads the entire response body into a byte array
|
||||||
|
// 4. Returns a tuple containing the response and body
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - client: The HTTP client to use for executing the request
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - A function that takes a Requester and returns a ReaderIOEither[FullResponse]
|
||||||
|
// where FullResponse is a tuple of (*http.Response, []byte)
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// client := MakeClient(http.DefaultClient)
|
||||||
|
// request := MakeGetRequest("https://api.example.com/data")
|
||||||
|
// fullResp := ReadFullResponse(client)(request)
|
||||||
|
// result := fullResp(context.Background())()
|
||||||
func ReadFullResponse(client Client) func(Requester) RIOE.ReaderIOEither[H.FullResponse] {
|
func ReadFullResponse(client Client) func(Requester) RIOE.ReaderIOEither[H.FullResponse] {
|
||||||
return func(req Requester) RIOE.ReaderIOEither[H.FullResponse] {
|
return func(req Requester) RIOE.ReaderIOEither[H.FullResponse] {
|
||||||
return F.Flow3(
|
return F.Flow3(
|
||||||
@@ -86,7 +178,23 @@ func ReadFullResponse(client Client) func(Requester) RIOE.ReaderIOEither[H.FullR
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReadAll sends a request and reads the response as bytes
|
// ReadAll sends an HTTP request and reads the complete response body as a byte array.
|
||||||
|
// It validates the HTTP status code and returns the raw response body bytes.
|
||||||
|
// This is useful when you need to process the response body in a custom way.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - client: The HTTP client to use for executing the request
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - A function that takes a Requester and returns a ReaderIOEither[[]byte]
|
||||||
|
// containing the response body as bytes
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// client := MakeClient(http.DefaultClient)
|
||||||
|
// request := MakeGetRequest("https://api.example.com/data")
|
||||||
|
// readBytes := ReadAll(client)
|
||||||
|
// result := readBytes(request)(context.Background())()
|
||||||
func ReadAll(client Client) func(Requester) RIOE.ReaderIOEither[[]byte] {
|
func ReadAll(client Client) func(Requester) RIOE.ReaderIOEither[[]byte] {
|
||||||
return F.Flow2(
|
return F.Flow2(
|
||||||
ReadFullResponse(client),
|
ReadFullResponse(client),
|
||||||
@@ -94,7 +202,23 @@ func ReadAll(client Client) func(Requester) RIOE.ReaderIOEither[[]byte] {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReadText sends a request, reads the response and represents the response as a text string
|
// ReadText sends an HTTP request, reads the response body, and converts it to a string.
|
||||||
|
// It validates the HTTP status code and returns the response body as a UTF-8 string.
|
||||||
|
// This is convenient for APIs that return plain text responses.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - client: The HTTP client to use for executing the request
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - A function that takes a Requester and returns a ReaderIOEither[string]
|
||||||
|
// containing the response body as a string
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// client := MakeClient(http.DefaultClient)
|
||||||
|
// request := MakeGetRequest("https://api.example.com/text")
|
||||||
|
// readText := ReadText(client)
|
||||||
|
// result := readText(request)(context.Background())()
|
||||||
func ReadText(client Client) func(Requester) RIOE.ReaderIOEither[string] {
|
func ReadText(client Client) func(Requester) RIOE.ReaderIOEither[string] {
|
||||||
return F.Flow2(
|
return F.Flow2(
|
||||||
ReadAll(client),
|
ReadAll(client),
|
||||||
@@ -102,13 +226,22 @@ func ReadText(client Client) func(Requester) RIOE.ReaderIOEither[string] {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReadJson sends a request, reads the response and parses the response as JSON
|
// ReadJson sends an HTTP request, reads the response, and parses it as JSON.
|
||||||
//
|
//
|
||||||
// Deprecated: use [ReadJSON] instead
|
// Deprecated: Use [ReadJSON] instead. This function is kept for backward compatibility
|
||||||
|
// but will be removed in a future version. The capitalized version follows Go naming
|
||||||
|
// conventions for acronyms.
|
||||||
func ReadJson[A any](client Client) func(Requester) RIOE.ReaderIOEither[A] {
|
func ReadJson[A any](client Client) func(Requester) RIOE.ReaderIOEither[A] {
|
||||||
return ReadJSON[A](client)
|
return ReadJSON[A](client)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// readJSON is an internal helper that reads the response body and validates JSON content type.
|
||||||
|
// It performs the following validations:
|
||||||
|
// 1. Validates HTTP status code
|
||||||
|
// 2. Validates that the response Content-Type is application/json
|
||||||
|
// 3. Reads the response body as bytes
|
||||||
|
//
|
||||||
|
// This function is used internally by ReadJSON to ensure proper JSON response handling.
|
||||||
func readJSON(client Client) func(Requester) RIOE.ReaderIOEither[[]byte] {
|
func readJSON(client Client) func(Requester) RIOE.ReaderIOEither[[]byte] {
|
||||||
return F.Flow3(
|
return F.Flow3(
|
||||||
ReadFullResponse(client),
|
ReadFullResponse(client),
|
||||||
@@ -120,7 +253,31 @@ func readJSON(client Client) func(Requester) RIOE.ReaderIOEither[[]byte] {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReadJSON sends a request, reads the response and parses the response as JSON
|
// ReadJSON sends an HTTP request, reads the response, and parses it as JSON into type A.
|
||||||
|
// It validates both the HTTP status code and the Content-Type header to ensure the
|
||||||
|
// response is valid JSON before attempting to unmarshal.
|
||||||
|
//
|
||||||
|
// Type Parameters:
|
||||||
|
// - A: The target type to unmarshal the JSON response into
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - client: The HTTP client to use for executing the request
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - A function that takes a Requester and returns a ReaderIOEither[A]
|
||||||
|
// containing the parsed JSON data
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// type User struct {
|
||||||
|
// ID int `json:"id"`
|
||||||
|
// Name string `json:"name"`
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// client := MakeClient(http.DefaultClient)
|
||||||
|
// request := MakeGetRequest("https://api.example.com/user/1")
|
||||||
|
// readUser := ReadJSON[User](client)
|
||||||
|
// result := readUser(request)(context.Background())()
|
||||||
func ReadJSON[A any](client Client) func(Requester) RIOE.ReaderIOEither[A] {
|
func ReadJSON[A any](client Client) func(Requester) RIOE.ReaderIOEither[A] {
|
||||||
return F.Flow2(
|
return F.Flow2(
|
||||||
readJSON(client),
|
readJSON(client),
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ func TestSendSingleRequest(t *testing.T) {
|
|||||||
|
|
||||||
resp1 := readItem(req1)
|
resp1 := readItem(req1)
|
||||||
|
|
||||||
resE := resp1(context.TODO())()
|
resE := resp1(t.Context())()
|
||||||
|
|
||||||
fmt.Println(resE)
|
fmt.Println(resE)
|
||||||
}
|
}
|
||||||
@@ -121,7 +121,7 @@ func TestSendSingleRequestWithHeaderUnsafe(t *testing.T) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
res := F.Pipe1(
|
res := F.Pipe1(
|
||||||
resp1(context.TODO())(),
|
resp1(t.Context())(),
|
||||||
E.GetOrElse(errors.ToString),
|
E.GetOrElse(errors.ToString),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -149,9 +149,167 @@ func TestSendSingleRequestWithHeaderSafe(t *testing.T) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
res := F.Pipe1(
|
res := F.Pipe1(
|
||||||
response(context.TODO())(),
|
response(t.Context())(),
|
||||||
E.GetOrElse(errors.ToString),
|
E.GetOrElse(errors.ToString),
|
||||||
)
|
)
|
||||||
|
|
||||||
assert.Equal(t, "sunt aut facere repellat provident occaecati excepturi optio reprehenderit", res)
|
assert.Equal(t, "sunt aut facere repellat provident occaecati excepturi optio reprehenderit", res)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestReadAll tests the ReadAll function which reads response as bytes
|
||||||
|
func TestReadAll(t *testing.T) {
|
||||||
|
client := MakeClient(H.DefaultClient)
|
||||||
|
|
||||||
|
request := MakeGetRequest("https://jsonplaceholder.typicode.com/posts/1")
|
||||||
|
readBytes := ReadAll(client)
|
||||||
|
|
||||||
|
result := readBytes(request)(t.Context())()
|
||||||
|
|
||||||
|
assert.True(t, E.IsRight(result), "Expected Right result")
|
||||||
|
|
||||||
|
bytes := E.GetOrElse(func(error) []byte { return nil })(result)
|
||||||
|
assert.NotNil(t, bytes, "Expected non-nil bytes")
|
||||||
|
assert.Greater(t, len(bytes), 0, "Expected non-empty byte array")
|
||||||
|
|
||||||
|
// Verify it contains expected JSON content
|
||||||
|
content := string(bytes)
|
||||||
|
assert.Contains(t, content, "userId")
|
||||||
|
assert.Contains(t, content, "title")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestReadText tests the ReadText function which reads response as string
|
||||||
|
func TestReadText(t *testing.T) {
|
||||||
|
client := MakeClient(H.DefaultClient)
|
||||||
|
|
||||||
|
request := MakeGetRequest("https://jsonplaceholder.typicode.com/posts/1")
|
||||||
|
readText := ReadText(client)
|
||||||
|
|
||||||
|
result := readText(request)(t.Context())()
|
||||||
|
|
||||||
|
assert.True(t, E.IsRight(result), "Expected Right result")
|
||||||
|
|
||||||
|
text := E.GetOrElse(func(error) string { return "" })(result)
|
||||||
|
assert.NotEmpty(t, text, "Expected non-empty text")
|
||||||
|
|
||||||
|
// Verify it contains expected JSON content as text
|
||||||
|
assert.Contains(t, text, "userId")
|
||||||
|
assert.Contains(t, text, "title")
|
||||||
|
assert.Contains(t, text, "sunt aut facere")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestReadJson tests the deprecated ReadJson function
|
||||||
|
func TestReadJson(t *testing.T) {
|
||||||
|
client := MakeClient(H.DefaultClient)
|
||||||
|
|
||||||
|
request := MakeGetRequest("https://jsonplaceholder.typicode.com/posts/1")
|
||||||
|
readItem := ReadJson[PostItem](client)
|
||||||
|
|
||||||
|
result := readItem(request)(t.Context())()
|
||||||
|
|
||||||
|
assert.True(t, E.IsRight(result), "Expected Right result")
|
||||||
|
|
||||||
|
item := E.GetOrElse(func(error) PostItem { return PostItem{} })(result)
|
||||||
|
assert.Equal(t, uint(1), item.UserID, "Expected UserID to be 1")
|
||||||
|
assert.Equal(t, uint(1), item.Id, "Expected Id to be 1")
|
||||||
|
assert.NotEmpty(t, item.Title, "Expected non-empty title")
|
||||||
|
assert.NotEmpty(t, item.Body, "Expected non-empty body")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestReadAllWithInvalidURL tests ReadAll with an invalid URL
|
||||||
|
func TestReadAllWithInvalidURL(t *testing.T) {
|
||||||
|
client := MakeClient(H.DefaultClient)
|
||||||
|
|
||||||
|
request := MakeGetRequest("http://invalid-domain-that-does-not-exist-12345.com")
|
||||||
|
readBytes := ReadAll(client)
|
||||||
|
|
||||||
|
result := readBytes(request)(t.Context())()
|
||||||
|
|
||||||
|
assert.True(t, E.IsLeft(result), "Expected Left result for invalid URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestReadTextWithInvalidURL tests ReadText with an invalid URL
|
||||||
|
func TestReadTextWithInvalidURL(t *testing.T) {
|
||||||
|
client := MakeClient(H.DefaultClient)
|
||||||
|
|
||||||
|
request := MakeGetRequest("http://invalid-domain-that-does-not-exist-12345.com")
|
||||||
|
readText := ReadText(client)
|
||||||
|
|
||||||
|
result := readText(request)(t.Context())()
|
||||||
|
|
||||||
|
assert.True(t, E.IsLeft(result), "Expected Left result for invalid URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestReadJSONWithInvalidURL tests ReadJSON with an invalid URL
|
||||||
|
func TestReadJSONWithInvalidURL(t *testing.T) {
|
||||||
|
client := MakeClient(H.DefaultClient)
|
||||||
|
|
||||||
|
request := MakeGetRequest("http://invalid-domain-that-does-not-exist-12345.com")
|
||||||
|
readItem := ReadJSON[PostItem](client)
|
||||||
|
|
||||||
|
result := readItem(request)(t.Context())()
|
||||||
|
|
||||||
|
assert.True(t, E.IsLeft(result), "Expected Left result for invalid URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestReadJSONWithInvalidJSON tests ReadJSON with non-JSON response
|
||||||
|
func TestReadJSONWithInvalidJSON(t *testing.T) {
|
||||||
|
client := MakeClient(H.DefaultClient)
|
||||||
|
|
||||||
|
// This URL returns HTML, not JSON
|
||||||
|
request := MakeGetRequest("https://www.google.com")
|
||||||
|
readItem := ReadJSON[PostItem](client)
|
||||||
|
|
||||||
|
result := readItem(request)(t.Context())()
|
||||||
|
|
||||||
|
// Should fail because content-type is not application/json
|
||||||
|
assert.True(t, E.IsLeft(result), "Expected Left result for non-JSON response")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMakeClientWithCustomClient tests MakeClient with a custom http.Client
|
||||||
|
func TestMakeClientWithCustomClient(t *testing.T) {
|
||||||
|
customClient := H.DefaultClient
|
||||||
|
|
||||||
|
client := MakeClient(customClient)
|
||||||
|
assert.NotNil(t, client, "Expected non-nil client")
|
||||||
|
|
||||||
|
// Verify it works
|
||||||
|
request := MakeGetRequest("https://jsonplaceholder.typicode.com/posts/1")
|
||||||
|
readItem := ReadJSON[PostItem](client)
|
||||||
|
result := readItem(request)(t.Context())()
|
||||||
|
|
||||||
|
assert.True(t, E.IsRight(result), "Expected Right result")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestReadAllComposition tests composing ReadAll with other operations
|
||||||
|
func TestReadAllComposition(t *testing.T) {
|
||||||
|
client := MakeClient(H.DefaultClient)
|
||||||
|
|
||||||
|
request := MakeGetRequest("https://jsonplaceholder.typicode.com/posts/1")
|
||||||
|
|
||||||
|
// Compose ReadAll with a map operation to get byte length
|
||||||
|
readBytes := ReadAll(client)(request)
|
||||||
|
readLength := R.Map(func(bytes []byte) int { return len(bytes) })(readBytes)
|
||||||
|
|
||||||
|
result := readLength(t.Context())()
|
||||||
|
|
||||||
|
assert.True(t, E.IsRight(result), "Expected Right result")
|
||||||
|
length := E.GetOrElse(func(error) int { return 0 })(result)
|
||||||
|
assert.Greater(t, length, 0, "Expected positive byte length")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestReadTextComposition tests composing ReadText with other operations
|
||||||
|
func TestReadTextComposition(t *testing.T) {
|
||||||
|
client := MakeClient(H.DefaultClient)
|
||||||
|
|
||||||
|
request := MakeGetRequest("https://jsonplaceholder.typicode.com/posts/1")
|
||||||
|
|
||||||
|
// Compose ReadText with a map operation to get string length
|
||||||
|
readText := ReadText(client)(request)
|
||||||
|
readLength := R.Map(func(text string) int { return len(text) })(readText)
|
||||||
|
|
||||||
|
result := readLength(t.Context())()
|
||||||
|
|
||||||
|
assert.True(t, E.IsRight(result), "Expected Right result")
|
||||||
|
length := E.GetOrElse(func(error) int { return 0 })(result)
|
||||||
|
assert.Greater(t, length, 0, "Expected positive string length")
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,6 +13,62 @@
|
|||||||
// See the License for the specific language governing permissions and
|
// See the License for the specific language governing permissions and
|
||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
|
|
||||||
|
// Package builder provides a functional, immutable HTTP request builder with composable operations.
|
||||||
|
// It follows functional programming principles to construct HTTP requests in a type-safe,
|
||||||
|
// testable, and maintainable way.
|
||||||
|
//
|
||||||
|
// The Builder type is immutable - all operations return a new builder instance rather than
|
||||||
|
// modifying the existing one. This ensures thread-safety and makes the code easier to reason about.
|
||||||
|
//
|
||||||
|
// Key Features:
|
||||||
|
// - Immutable builder pattern with method chaining
|
||||||
|
// - Lens-based access to builder properties
|
||||||
|
// - Support for headers, query parameters, request body, and HTTP methods
|
||||||
|
// - JSON and form data encoding
|
||||||
|
// - URL construction with query parameter merging
|
||||||
|
// - Hash generation for caching
|
||||||
|
// - Bearer token authentication helpers
|
||||||
|
//
|
||||||
|
// Basic Usage:
|
||||||
|
//
|
||||||
|
// import (
|
||||||
|
// B "github.com/IBM/fp-go/v2/http/builder"
|
||||||
|
// F "github.com/IBM/fp-go/v2/function"
|
||||||
|
// )
|
||||||
|
//
|
||||||
|
// // Build a simple GET request
|
||||||
|
// builder := F.Pipe2(
|
||||||
|
// B.Default,
|
||||||
|
// B.WithURL("https://api.example.com/users"),
|
||||||
|
// B.WithHeader("Accept")("application/json"),
|
||||||
|
// )
|
||||||
|
//
|
||||||
|
// // Build a POST request with JSON body
|
||||||
|
// builder := F.Pipe3(
|
||||||
|
// B.Default,
|
||||||
|
// B.WithURL("https://api.example.com/users"),
|
||||||
|
// B.WithMethod("POST"),
|
||||||
|
// B.WithJSON(map[string]string{"name": "John"}),
|
||||||
|
// )
|
||||||
|
//
|
||||||
|
// // Build a request with query parameters
|
||||||
|
// builder := F.Pipe3(
|
||||||
|
// B.Default,
|
||||||
|
// B.WithURL("https://api.example.com/search"),
|
||||||
|
// B.WithQueryArg("q")("golang"),
|
||||||
|
// B.WithQueryArg("limit")("10"),
|
||||||
|
// )
|
||||||
|
//
|
||||||
|
// The package provides several convenience functions for common HTTP methods:
|
||||||
|
// - WithGet, WithPost, WithPut, WithDelete for setting HTTP methods
|
||||||
|
// - WithBearer for adding Bearer token authentication
|
||||||
|
// - WithJSON for JSON payloads
|
||||||
|
// - WithFormData for form-encoded payloads
|
||||||
|
//
|
||||||
|
// Lenses are provided for advanced use cases:
|
||||||
|
// - URL, Method, Body, Headers, Query for accessing builder properties
|
||||||
|
// - Header(name) for accessing individual headers
|
||||||
|
// - QueryArg(name) for accessing individual query parameters
|
||||||
package builder
|
package builder
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|||||||
@@ -17,8 +17,11 @@ package builder
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
E "github.com/IBM/fp-go/v2/either"
|
||||||
F "github.com/IBM/fp-go/v2/function"
|
F "github.com/IBM/fp-go/v2/function"
|
||||||
C "github.com/IBM/fp-go/v2/http/content"
|
C "github.com/IBM/fp-go/v2/http/content"
|
||||||
FD "github.com/IBM/fp-go/v2/http/form"
|
FD "github.com/IBM/fp-go/v2/http/form"
|
||||||
@@ -91,3 +94,351 @@ func TestHash(t *testing.T) {
|
|||||||
|
|
||||||
fmt.Println(MakeHash(b1))
|
fmt.Println(MakeHash(b1))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestGetTargetURL tests URL construction with query parameters
|
||||||
|
func TestGetTargetURL(t *testing.T) {
|
||||||
|
builder := F.Pipe3(
|
||||||
|
Default,
|
||||||
|
WithURL("http://www.example.com?existing=param"),
|
||||||
|
WithQueryArg("limit")("10"),
|
||||||
|
WithQueryArg("offset")("20"),
|
||||||
|
)
|
||||||
|
|
||||||
|
result := builder.GetTargetURL()
|
||||||
|
assert.True(t, E.IsRight(result), "Expected Right result")
|
||||||
|
|
||||||
|
url := E.GetOrElse(func(error) string { return "" })(result)
|
||||||
|
assert.Contains(t, url, "limit=10")
|
||||||
|
assert.Contains(t, url, "offset=20")
|
||||||
|
assert.Contains(t, url, "existing=param")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGetTargetURLWithInvalidURL tests error handling for invalid URLs
|
||||||
|
func TestGetTargetURLWithInvalidURL(t *testing.T) {
|
||||||
|
builder := F.Pipe1(
|
||||||
|
Default,
|
||||||
|
WithURL("://invalid-url"),
|
||||||
|
)
|
||||||
|
|
||||||
|
result := builder.GetTargetURL()
|
||||||
|
assert.True(t, E.IsLeft(result), "Expected Left result for invalid URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGetTargetUrl tests the deprecated GetTargetUrl function
|
||||||
|
func TestGetTargetUrl(t *testing.T) {
|
||||||
|
builder := F.Pipe2(
|
||||||
|
Default,
|
||||||
|
WithURL("http://www.example.com"),
|
||||||
|
WithQueryArg("test")("value"),
|
||||||
|
)
|
||||||
|
|
||||||
|
result := builder.GetTargetUrl()
|
||||||
|
assert.True(t, E.IsRight(result), "Expected Right result")
|
||||||
|
|
||||||
|
url := E.GetOrElse(func(error) string { return "" })(result)
|
||||||
|
assert.Contains(t, url, "test=value")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSetMethod tests the SetMethod function
|
||||||
|
func TestSetMethod(t *testing.T) {
|
||||||
|
builder := Default.SetMethod("POST")
|
||||||
|
|
||||||
|
assert.Equal(t, "POST", builder.GetMethod())
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSetQuery tests the SetQuery function
|
||||||
|
func TestSetQuery(t *testing.T) {
|
||||||
|
query := make(url.Values)
|
||||||
|
query.Set("key1", "value1")
|
||||||
|
query.Set("key2", "value2")
|
||||||
|
|
||||||
|
builder := Default.SetQuery(query)
|
||||||
|
|
||||||
|
assert.Equal(t, "value1", builder.GetQuery().Get("key1"))
|
||||||
|
assert.Equal(t, "value2", builder.GetQuery().Get("key2"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSetHeaders tests the SetHeaders function
|
||||||
|
func TestSetHeaders(t *testing.T) {
|
||||||
|
headers := make(http.Header)
|
||||||
|
headers.Set("X-Custom-Header", "custom-value")
|
||||||
|
headers.Set("Authorization", "Bearer token")
|
||||||
|
|
||||||
|
builder := Default.SetHeaders(headers)
|
||||||
|
|
||||||
|
assert.Equal(t, "custom-value", builder.GetHeaders().Get("X-Custom-Header"))
|
||||||
|
assert.Equal(t, "Bearer token", builder.GetHeaders().Get("Authorization"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGetHeaderValues tests the GetHeaderValues function
|
||||||
|
func TestGetHeaderValues(t *testing.T) {
|
||||||
|
builder := F.Pipe2(
|
||||||
|
Default,
|
||||||
|
WithHeader("Accept")("application/json"),
|
||||||
|
WithHeader("Accept")("text/html"),
|
||||||
|
)
|
||||||
|
|
||||||
|
values := builder.GetHeaderValues("Accept")
|
||||||
|
assert.Contains(t, values, "text/html")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGetUrl tests the deprecated GetUrl function
|
||||||
|
func TestGetUrl(t *testing.T) {
|
||||||
|
builder := F.Pipe1(
|
||||||
|
Default,
|
||||||
|
WithURL("http://www.example.com"),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.Equal(t, "http://www.example.com", builder.GetUrl())
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSetUrl tests the deprecated SetUrl function
|
||||||
|
func TestSetUrl(t *testing.T) {
|
||||||
|
builder := Default.SetUrl("http://www.example.com")
|
||||||
|
|
||||||
|
assert.Equal(t, "http://www.example.com", builder.GetURL())
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWithJson tests the deprecated WithJson function
|
||||||
|
func TestWithJson(t *testing.T) {
|
||||||
|
data := map[string]string{"key": "value"}
|
||||||
|
|
||||||
|
builder := F.Pipe1(
|
||||||
|
Default,
|
||||||
|
WithJson(data),
|
||||||
|
)
|
||||||
|
|
||||||
|
contentType := O.GetOrElse(F.Constant(""))(builder.GetHeader(H.ContentType))
|
||||||
|
assert.Equal(t, C.JSON, contentType)
|
||||||
|
assert.True(t, O.IsSome(builder.GetBody()))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestQueryArg tests the QueryArg lens
|
||||||
|
func TestQueryArg(t *testing.T) {
|
||||||
|
lens := QueryArg("test")
|
||||||
|
|
||||||
|
builder := F.Pipe1(
|
||||||
|
Default,
|
||||||
|
lens.Set(O.Some("value")),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.Equal(t, O.Some("value"), lens.Get(builder))
|
||||||
|
assert.Equal(t, "value", builder.GetQuery().Get("test"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWithQueryArg tests the WithQueryArg function
|
||||||
|
func TestWithQueryArg(t *testing.T) {
|
||||||
|
builder := F.Pipe2(
|
||||||
|
Default,
|
||||||
|
WithQueryArg("param1")("value1"),
|
||||||
|
WithQueryArg("param2")("value2"),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.Equal(t, "value1", builder.GetQuery().Get("param1"))
|
||||||
|
assert.Equal(t, "value2", builder.GetQuery().Get("param2"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWithoutQueryArg tests the WithoutQueryArg function
|
||||||
|
func TestWithoutQueryArg(t *testing.T) {
|
||||||
|
builder := F.Pipe3(
|
||||||
|
Default,
|
||||||
|
WithQueryArg("param1")("value1"),
|
||||||
|
WithQueryArg("param2")("value2"),
|
||||||
|
WithoutQueryArg("param1"),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.Equal(t, "", builder.GetQuery().Get("param1"))
|
||||||
|
assert.Equal(t, "value2", builder.GetQuery().Get("param2"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGetHash tests the GetHash method
|
||||||
|
func TestGetHash(t *testing.T) {
|
||||||
|
builder := F.Pipe2(
|
||||||
|
Default,
|
||||||
|
WithURL("http://www.example.com"),
|
||||||
|
WithMethod("POST"),
|
||||||
|
)
|
||||||
|
|
||||||
|
hash := builder.GetHash()
|
||||||
|
assert.NotEmpty(t, hash)
|
||||||
|
assert.Equal(t, MakeHash(builder), hash)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWithBytes tests the WithBytes function
|
||||||
|
func TestWithBytes(t *testing.T) {
|
||||||
|
data := []byte("test data")
|
||||||
|
|
||||||
|
builder := F.Pipe1(
|
||||||
|
Default,
|
||||||
|
WithBytes(data),
|
||||||
|
)
|
||||||
|
|
||||||
|
body := builder.GetBody()
|
||||||
|
assert.True(t, O.IsSome(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWithoutBody tests the WithoutBody function
|
||||||
|
func TestWithoutBody(t *testing.T) {
|
||||||
|
builder := F.Pipe2(
|
||||||
|
Default,
|
||||||
|
WithBytes([]byte("data")),
|
||||||
|
WithoutBody,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.True(t, O.IsNone(builder.GetBody()))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWithGet tests the WithGet convenience function
|
||||||
|
func TestWithGet(t *testing.T) {
|
||||||
|
builder := F.Pipe1(
|
||||||
|
Default,
|
||||||
|
WithGet,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.Equal(t, "GET", builder.GetMethod())
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWithPost tests the WithPost convenience function
|
||||||
|
func TestWithPost(t *testing.T) {
|
||||||
|
builder := F.Pipe1(
|
||||||
|
Default,
|
||||||
|
WithPost,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.Equal(t, "POST", builder.GetMethod())
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWithPut tests the WithPut convenience function
|
||||||
|
func TestWithPut(t *testing.T) {
|
||||||
|
builder := F.Pipe1(
|
||||||
|
Default,
|
||||||
|
WithPut,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.Equal(t, "PUT", builder.GetMethod())
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWithDelete tests the WithDelete convenience function
|
||||||
|
func TestWithDelete(t *testing.T) {
|
||||||
|
builder := F.Pipe1(
|
||||||
|
Default,
|
||||||
|
WithDelete,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.Equal(t, "DELETE", builder.GetMethod())
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWithBearer tests the WithBearer function
|
||||||
|
func TestWithBearer(t *testing.T) {
|
||||||
|
builder := F.Pipe1(
|
||||||
|
Default,
|
||||||
|
WithBearer("my-token"),
|
||||||
|
)
|
||||||
|
|
||||||
|
auth := O.GetOrElse(F.Constant(""))(builder.GetHeader(H.Authorization))
|
||||||
|
assert.Equal(t, "Bearer my-token", auth)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWithContentType tests the WithContentType function
|
||||||
|
func TestWithContentType(t *testing.T) {
|
||||||
|
builder := F.Pipe1(
|
||||||
|
Default,
|
||||||
|
WithContentType(C.TextPlain),
|
||||||
|
)
|
||||||
|
|
||||||
|
contentType := O.GetOrElse(F.Constant(""))(builder.GetHeader(H.ContentType))
|
||||||
|
assert.Equal(t, C.TextPlain, contentType)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWithAuthorization tests the WithAuthorization function
|
||||||
|
func TestWithAuthorization(t *testing.T) {
|
||||||
|
builder := F.Pipe1(
|
||||||
|
Default,
|
||||||
|
WithAuthorization("Basic abc123"),
|
||||||
|
)
|
||||||
|
|
||||||
|
auth := O.GetOrElse(F.Constant(""))(builder.GetHeader(H.Authorization))
|
||||||
|
assert.Equal(t, "Basic abc123", auth)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBuilderChaining tests that builder operations can be chained
|
||||||
|
func TestBuilderChaining(t *testing.T) {
|
||||||
|
builder := F.Pipe3(
|
||||||
|
Default,
|
||||||
|
WithURL("http://www.example.com"),
|
||||||
|
WithMethod("POST"),
|
||||||
|
WithHeader("X-Test")("test-value"),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Verify all operations were applied
|
||||||
|
assert.Equal(t, "http://www.example.com", builder.GetURL())
|
||||||
|
assert.Equal(t, "POST", builder.GetMethod())
|
||||||
|
|
||||||
|
testHeader := O.GetOrElse(F.Constant(""))(builder.GetHeader("X-Test"))
|
||||||
|
assert.Equal(t, "test-value", testHeader)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWithQuery tests the WithQuery function
|
||||||
|
func TestWithQuery(t *testing.T) {
|
||||||
|
query := make(url.Values)
|
||||||
|
query.Set("key1", "value1")
|
||||||
|
query.Set("key2", "value2")
|
||||||
|
|
||||||
|
builder := F.Pipe1(
|
||||||
|
Default,
|
||||||
|
WithQuery(query),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.Equal(t, "value1", builder.GetQuery().Get("key1"))
|
||||||
|
assert.Equal(t, "value2", builder.GetQuery().Get("key2"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWithHeaders tests the WithHeaders function
|
||||||
|
func TestWithHeaders(t *testing.T) {
|
||||||
|
headers := make(http.Header)
|
||||||
|
headers.Set("X-Test", "test-value")
|
||||||
|
|
||||||
|
builder := F.Pipe1(
|
||||||
|
Default,
|
||||||
|
WithHeaders(headers),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.Equal(t, "test-value", builder.GetHeaders().Get("X-Test"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWithUrl tests the deprecated WithUrl function
|
||||||
|
func TestWithUrl(t *testing.T) {
|
||||||
|
builder := F.Pipe1(
|
||||||
|
Default,
|
||||||
|
WithUrl("http://www.example.com"),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.Equal(t, "http://www.example.com", builder.GetURL())
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestComplexBuilderComposition tests a complex builder composition
|
||||||
|
func TestComplexBuilderComposition(t *testing.T) {
|
||||||
|
builder := F.Pipe5(
|
||||||
|
Default,
|
||||||
|
WithURL("http://api.example.com/users"),
|
||||||
|
WithPost,
|
||||||
|
WithJSON(map[string]interface{}{
|
||||||
|
"name": "John Doe",
|
||||||
|
"email": "john@example.com",
|
||||||
|
}),
|
||||||
|
WithBearer("secret-token"),
|
||||||
|
WithQueryArg("notify")("true"),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.Equal(t, "http://api.example.com/users", builder.GetURL())
|
||||||
|
assert.Equal(t, "POST", builder.GetMethod())
|
||||||
|
|
||||||
|
contentType := O.GetOrElse(F.Constant(""))(builder.GetHeader(H.ContentType))
|
||||||
|
assert.Equal(t, C.JSON, contentType)
|
||||||
|
|
||||||
|
auth := O.GetOrElse(F.Constant(""))(builder.GetHeader(H.Authorization))
|
||||||
|
assert.Equal(t, "Bearer secret-token", auth)
|
||||||
|
|
||||||
|
assert.Equal(t, "true", builder.GetQuery().Get("notify"))
|
||||||
|
assert.True(t, O.IsSome(builder.GetBody()))
|
||||||
|
}
|
||||||
|
|||||||
36
v2/http/builder/coverage.out
Normal file
36
v2/http/builder/coverage.out
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
mode: set
|
||||||
|
github.com/IBM/fp-go/v2/http/builder/builder.go:208.51,211.2 2 1
|
||||||
|
github.com/IBM/fp-go/v2/http/builder/builder.go:213.37,215.2 1 1
|
||||||
|
github.com/IBM/fp-go/v2/http/builder/builder.go:217.42,221.2 3 1
|
||||||
|
github.com/IBM/fp-go/v2/http/builder/builder.go:226.64,228.2 1 1
|
||||||
|
github.com/IBM/fp-go/v2/http/builder/builder.go:231.64,257.2 1 1
|
||||||
|
github.com/IBM/fp-go/v2/http/builder/builder.go:260.41,262.2 1 1
|
||||||
|
github.com/IBM/fp-go/v2/http/builder/builder.go:264.41,266.2 1 1
|
||||||
|
github.com/IBM/fp-go/v2/http/builder/builder.go:268.44,273.2 1 1
|
||||||
|
github.com/IBM/fp-go/v2/http/builder/builder.go:275.50,277.2 1 1
|
||||||
|
github.com/IBM/fp-go/v2/http/builder/builder.go:279.47,281.2 1 1
|
||||||
|
github.com/IBM/fp-go/v2/http/builder/builder.go:283.61,286.2 2 1
|
||||||
|
github.com/IBM/fp-go/v2/http/builder/builder.go:288.69,290.2 1 1
|
||||||
|
github.com/IBM/fp-go/v2/http/builder/builder.go:292.59,295.2 2 1
|
||||||
|
github.com/IBM/fp-go/v2/http/builder/builder.go:298.53,301.2 2 1
|
||||||
|
github.com/IBM/fp-go/v2/http/builder/builder.go:303.53,306.2 2 1
|
||||||
|
github.com/IBM/fp-go/v2/http/builder/builder.go:308.66,311.2 2 1
|
||||||
|
github.com/IBM/fp-go/v2/http/builder/builder.go:313.82,316.2 2 1
|
||||||
|
github.com/IBM/fp-go/v2/http/builder/builder.go:318.64,321.2 2 1
|
||||||
|
github.com/IBM/fp-go/v2/http/builder/builder.go:323.57,326.2 2 1
|
||||||
|
github.com/IBM/fp-go/v2/http/builder/builder.go:328.65,334.2 1 1
|
||||||
|
github.com/IBM/fp-go/v2/http/builder/builder.go:336.63,338.2 1 1
|
||||||
|
github.com/IBM/fp-go/v2/http/builder/builder.go:341.42,343.2 1 1
|
||||||
|
github.com/IBM/fp-go/v2/http/builder/builder.go:346.61,354.75 4 1
|
||||||
|
github.com/IBM/fp-go/v2/http/builder/builder.go:354.75,360.3 2 1
|
||||||
|
github.com/IBM/fp-go/v2/http/builder/builder.go:364.62,369.2 1 1
|
||||||
|
github.com/IBM/fp-go/v2/http/builder/builder.go:372.46,374.2 1 1
|
||||||
|
github.com/IBM/fp-go/v2/http/builder/builder.go:379.43,381.2 1 1
|
||||||
|
github.com/IBM/fp-go/v2/http/builder/builder.go:384.43,393.2 1 1
|
||||||
|
github.com/IBM/fp-go/v2/http/builder/builder.go:396.63,401.2 1 1
|
||||||
|
github.com/IBM/fp-go/v2/http/builder/builder.go:404.64,409.2 1 1
|
||||||
|
github.com/IBM/fp-go/v2/http/builder/builder.go:412.48,414.2 1 1
|
||||||
|
github.com/IBM/fp-go/v2/http/builder/builder.go:416.68,419.2 2 1
|
||||||
|
github.com/IBM/fp-go/v2/http/builder/builder.go:421.84,424.2 2 1
|
||||||
|
github.com/IBM/fp-go/v2/http/builder/builder.go:426.35,438.2 7 1
|
||||||
|
github.com/IBM/fp-go/v2/http/builder/builder.go:441.34,443.2 1 1
|
||||||
@@ -13,6 +13,56 @@
|
|||||||
// See the License for the specific language governing permissions and
|
// See the License for the specific language governing permissions and
|
||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
|
|
||||||
|
// Package headers provides constants and utilities for working with HTTP headers
|
||||||
|
// in a functional programming style. It offers type-safe header name constants,
|
||||||
|
// monoid operations for combining headers, and lens-based access to header values.
|
||||||
|
//
|
||||||
|
// The package follows functional programming principles by providing:
|
||||||
|
// - Immutable operations through lenses
|
||||||
|
// - Monoid for combining header maps
|
||||||
|
// - Type-safe header name constants
|
||||||
|
// - Functional composition of header operations
|
||||||
|
//
|
||||||
|
// Constants:
|
||||||
|
//
|
||||||
|
// The package defines commonly used HTTP header names as constants:
|
||||||
|
// - Accept: The Accept request header
|
||||||
|
// - Authorization: The Authorization request header
|
||||||
|
// - ContentType: The Content-Type header
|
||||||
|
// - ContentLength: The Content-Length header
|
||||||
|
//
|
||||||
|
// Monoid:
|
||||||
|
//
|
||||||
|
// The Monoid provides a way to combine multiple http.Header maps:
|
||||||
|
//
|
||||||
|
// headers1 := make(http.Header)
|
||||||
|
// headers1.Set("X-Custom", "value1")
|
||||||
|
//
|
||||||
|
// headers2 := make(http.Header)
|
||||||
|
// headers2.Set("Authorization", "Bearer token")
|
||||||
|
//
|
||||||
|
// combined := Monoid.Concat(headers1, headers2)
|
||||||
|
// // combined now contains both headers
|
||||||
|
//
|
||||||
|
// Lenses:
|
||||||
|
//
|
||||||
|
// AtValues and AtValue provide lens-based access to header values:
|
||||||
|
//
|
||||||
|
// // AtValues focuses on all values of a header ([]string)
|
||||||
|
// contentTypeLens := AtValues("Content-Type")
|
||||||
|
// values := contentTypeLens.Get(headers)
|
||||||
|
//
|
||||||
|
// // AtValue focuses on the first value of a header (Option[string])
|
||||||
|
// authLens := AtValue("Authorization")
|
||||||
|
// token := authLens.Get(headers) // Returns Option[string]
|
||||||
|
//
|
||||||
|
// The lenses support functional updates:
|
||||||
|
//
|
||||||
|
// // Set a header value
|
||||||
|
// newHeaders := AtValue("Content-Type").Set(O.Some("application/json"))(headers)
|
||||||
|
//
|
||||||
|
// // Remove a header
|
||||||
|
// newHeaders := AtValue("X-Custom").Set(O.None[string]())(headers)
|
||||||
package headers
|
package headers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -27,30 +77,88 @@ import (
|
|||||||
RG "github.com/IBM/fp-go/v2/record/generic"
|
RG "github.com/IBM/fp-go/v2/record/generic"
|
||||||
)
|
)
|
||||||
|
|
||||||
// HTTP headers
|
// Common HTTP header name constants.
|
||||||
|
// These constants provide type-safe access to standard HTTP header names.
|
||||||
const (
|
const (
|
||||||
Accept = "Accept"
|
// Accept specifies the media types that are acceptable for the response.
|
||||||
|
// Example: "Accept: application/json"
|
||||||
|
Accept = "Accept"
|
||||||
|
|
||||||
|
// Authorization contains credentials for authenticating the client with the server.
|
||||||
|
// Example: "Authorization: Bearer token123"
|
||||||
Authorization = "Authorization"
|
Authorization = "Authorization"
|
||||||
ContentType = "Content-Type"
|
|
||||||
|
// ContentType indicates the media type of the resource or data.
|
||||||
|
// Example: "Content-Type: application/json"
|
||||||
|
ContentType = "Content-Type"
|
||||||
|
|
||||||
|
// ContentLength indicates the size of the entity-body in bytes.
|
||||||
|
// Example: "Content-Length: 348"
|
||||||
ContentLength = "Content-Length"
|
ContentLength = "Content-Length"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
// Monoid is a [M.Monoid] to concatenate [http.Header] maps
|
// Monoid is a Monoid for combining http.Header maps.
|
||||||
|
// It uses a union operation where values from both headers are preserved.
|
||||||
|
// When the same header exists in both maps, the values are concatenated.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
// h1 := make(http.Header)
|
||||||
|
// h1.Set("X-Custom", "value1")
|
||||||
|
//
|
||||||
|
// h2 := make(http.Header)
|
||||||
|
// h2.Set("Authorization", "Bearer token")
|
||||||
|
//
|
||||||
|
// combined := Monoid.Concat(h1, h2)
|
||||||
|
// // combined contains both X-Custom and Authorization headers
|
||||||
Monoid = RG.UnionMonoid[http.Header](A.Semigroup[string]())
|
Monoid = RG.UnionMonoid[http.Header](A.Semigroup[string]())
|
||||||
|
|
||||||
// AtValues is a [L.Lens] that focusses on the values of a header
|
// AtValues is a Lens that focuses on all values of a specific header.
|
||||||
|
// It returns a lens that accesses the []string slice of header values.
|
||||||
|
// The header name is automatically canonicalized using MIME header key rules.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - name: The header name (will be canonicalized)
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - A Lens[http.Header, []string] focusing on the header's values
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
// lens := AtValues("Content-Type")
|
||||||
|
// values := lens.Get(headers) // Returns []string
|
||||||
|
// newHeaders := lens.Set([]string{"application/json"})(headers)
|
||||||
AtValues = F.Flow2(
|
AtValues = F.Flow2(
|
||||||
textproto.CanonicalMIMEHeaderKey,
|
textproto.CanonicalMIMEHeaderKey,
|
||||||
LRG.AtRecord[http.Header, []string],
|
LRG.AtRecord[http.Header, []string],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// composeHead is an internal helper that composes a lens to focus on the first
|
||||||
|
// element of a string array, returning an Option[string].
|
||||||
composeHead = F.Pipe1(
|
composeHead = F.Pipe1(
|
||||||
LA.AtHead[string](),
|
LA.AtHead[string](),
|
||||||
L.ComposeOptions[http.Header, string](A.Empty[string]()),
|
L.ComposeOptions[http.Header, string](A.Empty[string]()),
|
||||||
)
|
)
|
||||||
|
|
||||||
// AtValue is a [L.Lens] that focusses on first value of a header
|
// AtValue is a Lens that focuses on the first value of a specific header.
|
||||||
|
// It returns a lens that accesses an Option[string] representing the first
|
||||||
|
// header value, or None if the header doesn't exist.
|
||||||
|
// The header name is automatically canonicalized using MIME header key rules.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - name: The header name (will be canonicalized)
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - A Lens[http.Header, Option[string]] focusing on the first header value
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
// lens := AtValue("Authorization")
|
||||||
|
// token := lens.Get(headers) // Returns Option[string]
|
||||||
|
//
|
||||||
|
// // Set a header value
|
||||||
|
// newHeaders := lens.Set(O.Some("Bearer token"))(headers)
|
||||||
|
//
|
||||||
|
// // Remove a header
|
||||||
|
// newHeaders := lens.Set(O.None[string]())(headers)
|
||||||
AtValue = F.Flow2(
|
AtValue = F.Flow2(
|
||||||
AtValues,
|
AtValues,
|
||||||
composeHead,
|
composeHead,
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import (
|
|||||||
|
|
||||||
A "github.com/IBM/fp-go/v2/array"
|
A "github.com/IBM/fp-go/v2/array"
|
||||||
"github.com/IBM/fp-go/v2/eq"
|
"github.com/IBM/fp-go/v2/eq"
|
||||||
|
F "github.com/IBM/fp-go/v2/function"
|
||||||
LT "github.com/IBM/fp-go/v2/optics/lens/testing"
|
LT "github.com/IBM/fp-go/v2/optics/lens/testing"
|
||||||
O "github.com/IBM/fp-go/v2/option"
|
O "github.com/IBM/fp-go/v2/option"
|
||||||
RG "github.com/IBM/fp-go/v2/record/generic"
|
RG "github.com/IBM/fp-go/v2/record/generic"
|
||||||
@@ -56,3 +57,281 @@ func TestLaws(t *testing.T) {
|
|||||||
assert.True(t, fieldLaws(v1, s1))
|
assert.True(t, fieldLaws(v1, s1))
|
||||||
assert.True(t, fieldLaws(v2, s1))
|
assert.True(t, fieldLaws(v2, s1))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestMonoidEmpty tests the Monoid empty (identity) element
|
||||||
|
func TestMonoidEmpty(t *testing.T) {
|
||||||
|
empty := Monoid.Empty()
|
||||||
|
assert.NotNil(t, empty)
|
||||||
|
assert.Equal(t, 0, len(empty))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMonoidConcat tests concatenating two header maps
|
||||||
|
func TestMonoidConcat(t *testing.T) {
|
||||||
|
h1 := make(http.Header)
|
||||||
|
h1.Set("X-Custom-1", "value1")
|
||||||
|
h1.Set("Authorization", "Bearer token1")
|
||||||
|
|
||||||
|
h2 := make(http.Header)
|
||||||
|
h2.Set("X-Custom-2", "value2")
|
||||||
|
h2.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
result := Monoid.Concat(h1, h2)
|
||||||
|
|
||||||
|
assert.Equal(t, "value1", result.Get("X-Custom-1"))
|
||||||
|
assert.Equal(t, "value2", result.Get("X-Custom-2"))
|
||||||
|
assert.Equal(t, "Bearer token1", result.Get("Authorization"))
|
||||||
|
assert.Equal(t, "application/json", result.Get("Content-Type"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMonoidConcatWithOverlap tests concatenating headers with overlapping keys
|
||||||
|
func TestMonoidConcatWithOverlap(t *testing.T) {
|
||||||
|
h1 := make(http.Header)
|
||||||
|
h1.Set("X-Custom", "value1")
|
||||||
|
|
||||||
|
h2 := make(http.Header)
|
||||||
|
h2.Add("X-Custom", "value2")
|
||||||
|
|
||||||
|
result := Monoid.Concat(h1, h2)
|
||||||
|
|
||||||
|
// Both values should be present
|
||||||
|
values := result.Values("X-Custom")
|
||||||
|
assert.Contains(t, values, "value1")
|
||||||
|
assert.Contains(t, values, "value2")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMonoidIdentity tests that concatenating with empty is identity
|
||||||
|
func TestMonoidIdentity(t *testing.T) {
|
||||||
|
h := make(http.Header)
|
||||||
|
h.Set("X-Test", "value")
|
||||||
|
|
||||||
|
empty := Monoid.Empty()
|
||||||
|
|
||||||
|
// Left identity: empty + h = h
|
||||||
|
leftResult := Monoid.Concat(empty, h)
|
||||||
|
assert.Equal(t, "value", leftResult.Get("X-Test"))
|
||||||
|
|
||||||
|
// Right identity: h + empty = h
|
||||||
|
rightResult := Monoid.Concat(h, empty)
|
||||||
|
assert.Equal(t, "value", rightResult.Get("X-Test"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAtValuesGet tests getting header values using AtValues lens
|
||||||
|
func TestAtValuesGet(t *testing.T) {
|
||||||
|
headers := make(http.Header)
|
||||||
|
headers.Set("Content-Type", "application/json")
|
||||||
|
headers.Add("Accept", "application/json")
|
||||||
|
headers.Add("Accept", "text/html")
|
||||||
|
|
||||||
|
// Get Content-Type values
|
||||||
|
ctLens := AtValues("Content-Type")
|
||||||
|
ctValuesOpt := ctLens.Get(headers)
|
||||||
|
assert.True(t, O.IsSome(ctValuesOpt))
|
||||||
|
ctValues := O.GetOrElse(F.Constant([]string{}))(ctValuesOpt)
|
||||||
|
assert.Equal(t, []string{"application/json"}, ctValues)
|
||||||
|
|
||||||
|
// Get Accept values (multiple)
|
||||||
|
acceptLens := AtValues("Accept")
|
||||||
|
acceptValuesOpt := acceptLens.Get(headers)
|
||||||
|
assert.True(t, O.IsSome(acceptValuesOpt))
|
||||||
|
acceptValues := O.GetOrElse(F.Constant([]string{}))(acceptValuesOpt)
|
||||||
|
assert.Equal(t, 2, len(acceptValues))
|
||||||
|
assert.Contains(t, acceptValues, "application/json")
|
||||||
|
assert.Contains(t, acceptValues, "text/html")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAtValuesSet tests setting header values using AtValues lens
|
||||||
|
func TestAtValuesSet(t *testing.T) {
|
||||||
|
headers := make(http.Header)
|
||||||
|
headers.Set("X-Old", "old-value")
|
||||||
|
|
||||||
|
lens := AtValues("Content-Type")
|
||||||
|
newHeaders := lens.Set(O.Some([]string{"application/json", "text/plain"}))(headers)
|
||||||
|
|
||||||
|
// New header should be set
|
||||||
|
values := newHeaders.Values("Content-Type")
|
||||||
|
assert.Equal(t, 2, len(values))
|
||||||
|
assert.Contains(t, values, "application/json")
|
||||||
|
assert.Contains(t, values, "text/plain")
|
||||||
|
|
||||||
|
// Old header should still exist
|
||||||
|
assert.Equal(t, "old-value", newHeaders.Get("X-Old"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAtValuesCanonical tests that header names are canonicalized
|
||||||
|
func TestAtValuesCanonical(t *testing.T) {
|
||||||
|
headers := make(http.Header)
|
||||||
|
headers.Set("content-type", "application/json")
|
||||||
|
|
||||||
|
// Access with different casing
|
||||||
|
lens := AtValues("Content-Type")
|
||||||
|
valuesOpt := lens.Get(headers)
|
||||||
|
|
||||||
|
assert.True(t, O.IsSome(valuesOpt))
|
||||||
|
values := O.GetOrElse(F.Constant([]string{}))(valuesOpt)
|
||||||
|
assert.Equal(t, []string{"application/json"}, values)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAtValueGet tests getting first header value using AtValue lens
|
||||||
|
func TestAtValueGet(t *testing.T) {
|
||||||
|
headers := make(http.Header)
|
||||||
|
headers.Set("Authorization", "Bearer token123")
|
||||||
|
|
||||||
|
lens := AtValue("Authorization")
|
||||||
|
value := lens.Get(headers)
|
||||||
|
|
||||||
|
assert.True(t, O.IsSome(value))
|
||||||
|
token := O.GetOrElse(F.Constant(""))(value)
|
||||||
|
assert.Equal(t, "Bearer token123", token)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAtValueGetNone tests getting non-existent header returns None
|
||||||
|
func TestAtValueGetNone(t *testing.T) {
|
||||||
|
headers := make(http.Header)
|
||||||
|
|
||||||
|
lens := AtValue("X-Non-Existent")
|
||||||
|
value := lens.Get(headers)
|
||||||
|
|
||||||
|
assert.True(t, O.IsNone(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAtValueSet tests setting header value using AtValue lens
|
||||||
|
func TestAtValueSet(t *testing.T) {
|
||||||
|
headers := make(http.Header)
|
||||||
|
|
||||||
|
lens := AtValue("Content-Type")
|
||||||
|
newHeaders := lens.Set(O.Some("application/json"))(headers)
|
||||||
|
|
||||||
|
value := lens.Get(newHeaders)
|
||||||
|
assert.True(t, O.IsSome(value))
|
||||||
|
|
||||||
|
ct := O.GetOrElse(F.Constant(""))(value)
|
||||||
|
assert.Equal(t, "application/json", ct)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAtValueSetNone tests removing header using AtValue lens
|
||||||
|
func TestAtValueSetNone(t *testing.T) {
|
||||||
|
headers := make(http.Header)
|
||||||
|
headers.Set("X-Custom", "value")
|
||||||
|
|
||||||
|
lens := AtValue("X-Custom")
|
||||||
|
newHeaders := lens.Set(O.None[string]())(headers)
|
||||||
|
|
||||||
|
value := lens.Get(newHeaders)
|
||||||
|
assert.True(t, O.IsNone(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAtValueMultipleValues tests AtValue with multiple header values
|
||||||
|
func TestAtValueMultipleValues(t *testing.T) {
|
||||||
|
headers := make(http.Header)
|
||||||
|
headers.Add("Accept", "application/json")
|
||||||
|
headers.Add("Accept", "text/html")
|
||||||
|
|
||||||
|
lens := AtValue("Accept")
|
||||||
|
value := lens.Get(headers)
|
||||||
|
|
||||||
|
assert.True(t, O.IsSome(value))
|
||||||
|
// Should get the first value
|
||||||
|
first := O.GetOrElse(F.Constant(""))(value)
|
||||||
|
assert.Equal(t, "application/json", first)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHeaderConstants tests that header constants are correct
|
||||||
|
func TestHeaderConstants(t *testing.T) {
|
||||||
|
assert.Equal(t, "Accept", Accept)
|
||||||
|
assert.Equal(t, "Authorization", Authorization)
|
||||||
|
assert.Equal(t, "Content-Type", ContentType)
|
||||||
|
assert.Equal(t, "Content-Length", ContentLength)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHeaderConstantsUsage tests using header constants with http.Header
|
||||||
|
func TestHeaderConstantsUsage(t *testing.T) {
|
||||||
|
headers := make(http.Header)
|
||||||
|
|
||||||
|
headers.Set(Accept, "application/json")
|
||||||
|
headers.Set(Authorization, "Bearer token")
|
||||||
|
headers.Set(ContentType, "application/json")
|
||||||
|
headers.Set(ContentLength, "1234")
|
||||||
|
|
||||||
|
assert.Equal(t, "application/json", headers.Get(Accept))
|
||||||
|
assert.Equal(t, "Bearer token", headers.Get(Authorization))
|
||||||
|
assert.Equal(t, "application/json", headers.Get(ContentType))
|
||||||
|
assert.Equal(t, "1234", headers.Get(ContentLength))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAtValueWithConstants tests using AtValue with header constants
|
||||||
|
func TestAtValueWithConstants(t *testing.T) {
|
||||||
|
headers := make(http.Header)
|
||||||
|
headers.Set(ContentType, "application/json")
|
||||||
|
|
||||||
|
lens := AtValue(ContentType)
|
||||||
|
value := lens.Get(headers)
|
||||||
|
|
||||||
|
assert.True(t, O.IsSome(value))
|
||||||
|
ct := O.GetOrElse(F.Constant(""))(value)
|
||||||
|
assert.Equal(t, "application/json", ct)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMonoidAssociativity tests that Monoid concatenation is associative
|
||||||
|
func TestMonoidAssociativity(t *testing.T) {
|
||||||
|
h1 := make(http.Header)
|
||||||
|
h1.Set("X-1", "value1")
|
||||||
|
|
||||||
|
h2 := make(http.Header)
|
||||||
|
h2.Set("X-2", "value2")
|
||||||
|
|
||||||
|
h3 := make(http.Header)
|
||||||
|
h3.Set("X-3", "value3")
|
||||||
|
|
||||||
|
// (h1 + h2) + h3
|
||||||
|
left := Monoid.Concat(Monoid.Concat(h1, h2), h3)
|
||||||
|
|
||||||
|
// h1 + (h2 + h3)
|
||||||
|
right := Monoid.Concat(h1, Monoid.Concat(h2, h3))
|
||||||
|
|
||||||
|
// Both should have all three headers
|
||||||
|
assert.Equal(t, "value1", left.Get("X-1"))
|
||||||
|
assert.Equal(t, "value2", left.Get("X-2"))
|
||||||
|
assert.Equal(t, "value3", left.Get("X-3"))
|
||||||
|
|
||||||
|
assert.Equal(t, "value1", right.Get("X-1"))
|
||||||
|
assert.Equal(t, "value2", right.Get("X-2"))
|
||||||
|
assert.Equal(t, "value3", right.Get("X-3"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAtValuesEmptyHeader tests AtValues with empty headers
|
||||||
|
func TestAtValuesEmptyHeader(t *testing.T) {
|
||||||
|
headers := make(http.Header)
|
||||||
|
|
||||||
|
lens := AtValues("X-Non-Existent")
|
||||||
|
valuesOpt := lens.Get(headers)
|
||||||
|
|
||||||
|
assert.True(t, O.IsNone(valuesOpt))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestComplexHeaderOperations tests complex operations combining lenses and monoid
|
||||||
|
func TestComplexHeaderOperations(t *testing.T) {
|
||||||
|
// Create initial headers
|
||||||
|
h1 := make(http.Header)
|
||||||
|
h1.Set("X-Initial", "initial")
|
||||||
|
|
||||||
|
// Use lens to add Content-Type
|
||||||
|
ctLens := AtValue(ContentType)
|
||||||
|
h2 := ctLens.Set(O.Some("application/json"))(h1)
|
||||||
|
|
||||||
|
// Use lens to add Authorization
|
||||||
|
authLens := AtValue(Authorization)
|
||||||
|
h3 := authLens.Set(O.Some("Bearer token"))(h2)
|
||||||
|
|
||||||
|
// Create additional headers
|
||||||
|
h4 := make(http.Header)
|
||||||
|
h4.Set("X-Additional", "additional")
|
||||||
|
|
||||||
|
// Combine using Monoid
|
||||||
|
final := Monoid.Concat(h3, h4)
|
||||||
|
|
||||||
|
// Verify all headers are present
|
||||||
|
assert.Equal(t, "initial", final.Get("X-Initial"))
|
||||||
|
assert.Equal(t, "application/json", final.Get(ContentType))
|
||||||
|
assert.Equal(t, "Bearer token", final.Get(Authorization))
|
||||||
|
assert.Equal(t, "additional", final.Get("X-Additional"))
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,6 +13,53 @@
|
|||||||
// See the License for the specific language governing permissions and
|
// See the License for the specific language governing permissions and
|
||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
|
|
||||||
|
// Package http provides functional programming utilities for working with HTTP
|
||||||
|
// requests and responses. It offers type-safe abstractions, validation functions,
|
||||||
|
// and utilities for handling HTTP operations in a functional style.
|
||||||
|
//
|
||||||
|
// The package includes:
|
||||||
|
// - Type definitions for HTTP responses with bodies
|
||||||
|
// - Validation functions for HTTP responses
|
||||||
|
// - JSON content type validation
|
||||||
|
// - Error handling with detailed HTTP error information
|
||||||
|
// - Functional utilities for accessing response components
|
||||||
|
//
|
||||||
|
// Types:
|
||||||
|
//
|
||||||
|
// FullResponse represents a complete HTTP response including both the response
|
||||||
|
// object and the body as a byte array. It's implemented as a Pair for functional
|
||||||
|
// composition:
|
||||||
|
//
|
||||||
|
// type FullResponse = Pair[*http.Response, []byte]
|
||||||
|
//
|
||||||
|
// The Response and Body functions provide lens-like access to the components:
|
||||||
|
//
|
||||||
|
// resp := Response(fullResponse) // Get *http.Response
|
||||||
|
// body := Body(fullResponse) // Get []byte
|
||||||
|
//
|
||||||
|
// Validation:
|
||||||
|
//
|
||||||
|
// ValidateResponse checks if an HTTP response has a successful status code (2xx):
|
||||||
|
//
|
||||||
|
// result := ValidateResponse(response)
|
||||||
|
// // Returns Either[error, *http.Response]
|
||||||
|
//
|
||||||
|
// ValidateJSONResponse validates both the status code and Content-Type header:
|
||||||
|
//
|
||||||
|
// result := ValidateJSONResponse(response)
|
||||||
|
// // Returns Either[error, *http.Response]
|
||||||
|
//
|
||||||
|
// Error Handling:
|
||||||
|
//
|
||||||
|
// HttpError provides detailed information about HTTP failures:
|
||||||
|
//
|
||||||
|
// err := StatusCodeError(response)
|
||||||
|
// if httpErr, ok := err.(*HttpError); ok {
|
||||||
|
// code := httpErr.StatusCode()
|
||||||
|
// headers := httpErr.Headers()
|
||||||
|
// body := httpErr.Body()
|
||||||
|
// url := httpErr.URL()
|
||||||
|
// }
|
||||||
package http
|
package http
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -22,11 +69,38 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type (
|
type (
|
||||||
// FullResponse represents a full http response, including headers and body
|
// FullResponse represents a complete HTTP response including both the
|
||||||
|
// *http.Response object and the response body as a byte slice.
|
||||||
|
//
|
||||||
|
// It's implemented as a Pair to enable functional composition and
|
||||||
|
// transformation of HTTP responses. This allows you to work with both
|
||||||
|
// the response metadata (status, headers) and body content together.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
// fullResp := MakePair(response, bodyBytes)
|
||||||
|
// resp := Response(fullResp) // Extract *http.Response
|
||||||
|
// body := Body(fullResp) // Extract []byte
|
||||||
FullResponse = P.Pair[*H.Response, []byte]
|
FullResponse = P.Pair[*H.Response, []byte]
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
// Response is a lens-like accessor that extracts the *http.Response
|
||||||
|
// from a FullResponse. It provides functional access to the response
|
||||||
|
// metadata including status code, headers, and other HTTP response fields.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
// fullResp := MakePair(response, bodyBytes)
|
||||||
|
// resp := Response(fullResp)
|
||||||
|
// statusCode := resp.StatusCode
|
||||||
Response = P.Head[*H.Response, []byte]
|
Response = P.Head[*H.Response, []byte]
|
||||||
Body = P.Tail[*H.Response, []byte]
|
|
||||||
|
// Body is a lens-like accessor that extracts the response body bytes
|
||||||
|
// from a FullResponse. It provides functional access to the raw body
|
||||||
|
// content without needing to read from an io.Reader.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
// fullResp := MakePair(response, bodyBytes)
|
||||||
|
// body := Body(fullResp)
|
||||||
|
// content := string(body)
|
||||||
|
Body = P.Tail[*H.Response, []byte]
|
||||||
)
|
)
|
||||||
|
|||||||
185
v2/http/utils.go
185
v2/http/utils.go
@@ -33,8 +33,29 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type (
|
type (
|
||||||
|
// ParsedMediaType represents a parsed MIME media type as a Pair.
|
||||||
|
// The first element is the media type string (e.g., "application/json"),
|
||||||
|
// and the second element is a map of parameters (e.g., {"charset": "utf-8"}).
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
// parsed := ParseMediaType("application/json; charset=utf-8")
|
||||||
|
// mediaType := P.Head(parsed) // "application/json"
|
||||||
|
// params := P.Tail(parsed) // map[string]string{"charset": "utf-8"}
|
||||||
ParsedMediaType = P.Pair[string, map[string]string]
|
ParsedMediaType = P.Pair[string, map[string]string]
|
||||||
|
|
||||||
|
// HttpError represents an HTTP error with detailed information about
|
||||||
|
// the failed request. It includes the status code, response headers,
|
||||||
|
// response body, and the URL that was accessed.
|
||||||
|
//
|
||||||
|
// This error type is created by StatusCodeError when an HTTP response
|
||||||
|
// has a non-successful status code (not 2xx).
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
// if httpErr, ok := err.(*HttpError); ok {
|
||||||
|
// fmt.Printf("Status: %d\n", httpErr.StatusCode())
|
||||||
|
// fmt.Printf("URL: %s\n", httpErr.URL())
|
||||||
|
// fmt.Printf("Body: %s\n", string(httpErr.Body()))
|
||||||
|
// }
|
||||||
HttpError struct {
|
HttpError struct {
|
||||||
statusCode int
|
statusCode int
|
||||||
headers H.Header
|
headers H.Header
|
||||||
@@ -44,11 +65,28 @@ type (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
// mime type to check if a media type matches
|
// isJSONMimeType is a regex matcher that checks if a media type is a valid JSON type.
|
||||||
|
// It matches "application/json" and variants like "application/vnd.api+json".
|
||||||
isJSONMimeType = regexp.MustCompile(`application/(?:\w+\+)?json`).MatchString
|
isJSONMimeType = regexp.MustCompile(`application/(?:\w+\+)?json`).MatchString
|
||||||
// ValidateResponse validates an HTTP response and returns an [E.Either] if the response is not a success
|
|
||||||
|
// ValidateResponse validates an HTTP response and returns an Either.
|
||||||
|
// It checks if the response has a successful status code (2xx range).
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - Right(*http.Response) if status code is 2xx
|
||||||
|
// - Left(error) with HttpError if status code is not 2xx
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
// result := ValidateResponse(response)
|
||||||
|
// E.Fold(
|
||||||
|
// func(err error) { /* handle error */ },
|
||||||
|
// func(resp *http.Response) { /* handle success */ },
|
||||||
|
// )(result)
|
||||||
ValidateResponse = E.FromPredicate(isValidStatus, StatusCodeError)
|
ValidateResponse = E.FromPredicate(isValidStatus, StatusCodeError)
|
||||||
// alidateJsonContentTypeString parses a content type a validates that it is valid JSON
|
|
||||||
|
// validateJSONContentTypeString parses a content type string and validates
|
||||||
|
// that it represents a valid JSON media type. This is an internal helper
|
||||||
|
// used by ValidateJSONResponse.
|
||||||
validateJSONContentTypeString = F.Flow2(
|
validateJSONContentTypeString = F.Flow2(
|
||||||
ParseMediaType,
|
ParseMediaType,
|
||||||
E.ChainFirst(F.Flow2(
|
E.ChainFirst(F.Flow2(
|
||||||
@@ -56,7 +94,21 @@ var (
|
|||||||
E.FromPredicate(isJSONMimeType, errors.OnSome[string]("mimetype [%s] is not a valid JSON content type")),
|
E.FromPredicate(isJSONMimeType, errors.OnSome[string]("mimetype [%s] is not a valid JSON content type")),
|
||||||
)),
|
)),
|
||||||
)
|
)
|
||||||
// ValidateJSONResponse checks if an HTTP response is a valid JSON response
|
|
||||||
|
// ValidateJSONResponse validates that an HTTP response is a valid JSON response.
|
||||||
|
// It checks both the status code (must be 2xx) and the Content-Type header
|
||||||
|
// (must be a JSON media type like "application/json").
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - Right(*http.Response) if response is valid JSON with 2xx status
|
||||||
|
// - Left(error) if status is not 2xx or Content-Type is not JSON
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
// result := ValidateJSONResponse(response)
|
||||||
|
// E.Fold(
|
||||||
|
// func(err error) { /* handle non-JSON or error response */ },
|
||||||
|
// func(resp *http.Response) { /* handle valid JSON response */ },
|
||||||
|
// )(result)
|
||||||
ValidateJSONResponse = F.Flow2(
|
ValidateJSONResponse = F.Flow2(
|
||||||
E.Of[error, *H.Response],
|
E.Of[error, *H.Response],
|
||||||
E.ChainFirst(F.Flow5(
|
E.ChainFirst(F.Flow5(
|
||||||
@@ -66,60 +118,175 @@ var (
|
|||||||
E.FromOption[string](errors.OnNone("unable to access the [%s] header", HeaderContentType)),
|
E.FromOption[string](errors.OnNone("unable to access the [%s] header", HeaderContentType)),
|
||||||
E.ChainFirst(validateJSONContentTypeString),
|
E.ChainFirst(validateJSONContentTypeString),
|
||||||
)))
|
)))
|
||||||
// ValidateJsonResponse checks if an HTTP response is a valid JSON response
|
|
||||||
|
// ValidateJsonResponse checks if an HTTP response is a valid JSON response.
|
||||||
//
|
//
|
||||||
// Deprecated: use [ValidateJSONResponse] instead
|
// Deprecated: use ValidateJSONResponse instead (note the capitalization).
|
||||||
ValidateJsonResponse = ValidateJSONResponse
|
ValidateJsonResponse = ValidateJSONResponse
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
// HeaderContentType is the standard HTTP Content-Type header name.
|
||||||
|
// It indicates the media type of the resource or data being sent.
|
||||||
|
//
|
||||||
|
// Example values:
|
||||||
|
// - "application/json"
|
||||||
|
// - "text/html; charset=utf-8"
|
||||||
|
// - "application/xml"
|
||||||
HeaderContentType = "Content-Type"
|
HeaderContentType = "Content-Type"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ParseMediaType parses a media type into a tuple
|
// ParseMediaType parses a MIME media type string into its components.
|
||||||
|
// It returns a ParsedMediaType (Pair) containing the media type and its parameters.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - mediaType: A media type string (e.g., "application/json; charset=utf-8")
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - Right(ParsedMediaType) with the parsed media type and parameters
|
||||||
|
// - Left(error) if the media type string is invalid
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// result := ParseMediaType("application/json; charset=utf-8")
|
||||||
|
// E.Map(func(parsed ParsedMediaType) {
|
||||||
|
// mediaType := P.Head(parsed) // "application/json"
|
||||||
|
// params := P.Tail(parsed) // map[string]string{"charset": "utf-8"}
|
||||||
|
// })(result)
|
||||||
func ParseMediaType(mediaType string) E.Either[error, ParsedMediaType] {
|
func ParseMediaType(mediaType string) E.Either[error, ParsedMediaType] {
|
||||||
m, p, err := mime.ParseMediaType(mediaType)
|
m, p, err := mime.ParseMediaType(mediaType)
|
||||||
return E.TryCatchError(P.MakePair(m, p), err)
|
return E.TryCatchError(P.MakePair(m, p), err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Error fulfills the error interface
|
// Error implements the error interface for HttpError.
|
||||||
|
// It returns a formatted error message including the status code and URL.
|
||||||
func (r *HttpError) Error() string {
|
func (r *HttpError) Error() string {
|
||||||
return fmt.Sprintf("invalid status code [%d] when accessing URL [%s]", r.statusCode, r.url)
|
return fmt.Sprintf("invalid status code [%d] when accessing URL [%s]", r.statusCode, r.url)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// String returns the string representation of the HttpError.
|
||||||
|
// It's equivalent to calling Error().
|
||||||
func (r *HttpError) String() string {
|
func (r *HttpError) String() string {
|
||||||
return r.Error()
|
return r.Error()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// StatusCode returns the HTTP status code from the failed response.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// if httpErr, ok := err.(*HttpError); ok {
|
||||||
|
// code := httpErr.StatusCode() // e.g., 404, 500
|
||||||
|
// }
|
||||||
func (r *HttpError) StatusCode() int {
|
func (r *HttpError) StatusCode() int {
|
||||||
return r.statusCode
|
return r.statusCode
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Headers returns a clone of the HTTP headers from the failed response.
|
||||||
|
// The headers are cloned to prevent modification of the original response.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// if httpErr, ok := err.(*HttpError); ok {
|
||||||
|
// headers := httpErr.Headers()
|
||||||
|
// contentType := headers.Get("Content-Type")
|
||||||
|
// }
|
||||||
func (r *HttpError) Headers() H.Header {
|
func (r *HttpError) Headers() H.Header {
|
||||||
return r.headers
|
return r.headers
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// URL returns the URL that was accessed when the error occurred.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// if httpErr, ok := err.(*HttpError); ok {
|
||||||
|
// url := httpErr.URL()
|
||||||
|
// fmt.Printf("Failed to access: %s\n", url)
|
||||||
|
// }
|
||||||
func (r *HttpError) URL() *url.URL {
|
func (r *HttpError) URL() *url.URL {
|
||||||
return r.url
|
return r.url
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Body returns the response body bytes from the failed response.
|
||||||
|
// This can be useful for debugging or displaying error messages from the server.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// if httpErr, ok := err.(*HttpError); ok {
|
||||||
|
// body := httpErr.Body()
|
||||||
|
// fmt.Printf("Error response: %s\n", string(body))
|
||||||
|
// }
|
||||||
func (r *HttpError) Body() []byte {
|
func (r *HttpError) Body() []byte {
|
||||||
return r.body
|
return r.body
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetHeader extracts the HTTP headers from an http.Response.
|
||||||
|
// This is a functional accessor for the Header field.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - resp: The HTTP response
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - The http.Header map from the response
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// headers := GetHeader(response)
|
||||||
|
// contentType := headers.Get("Content-Type")
|
||||||
func GetHeader(resp *H.Response) H.Header {
|
func GetHeader(resp *H.Response) H.Header {
|
||||||
return resp.Header
|
return resp.Header
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetBody extracts the response body reader from an http.Response.
|
||||||
|
// This is a functional accessor for the Body field.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - resp: The HTTP response
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - The io.ReadCloser for reading the response body
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// body := GetBody(response)
|
||||||
|
// defer body.Close()
|
||||||
|
// data, err := io.ReadAll(body)
|
||||||
func GetBody(resp *H.Response) io.ReadCloser {
|
func GetBody(resp *H.Response) io.ReadCloser {
|
||||||
return resp.Body
|
return resp.Body
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isValidStatus checks if an HTTP response has a successful status code.
|
||||||
|
// A status code is considered valid if it's in the 2xx range (200-299).
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - resp: The HTTP response to check
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - true if status code is 2xx, false otherwise
|
||||||
func isValidStatus(resp *H.Response) bool {
|
func isValidStatus(resp *H.Response) bool {
|
||||||
return resp.StatusCode >= H.StatusOK && resp.StatusCode < H.StatusMultipleChoices
|
return resp.StatusCode >= H.StatusOK && resp.StatusCode < H.StatusMultipleChoices
|
||||||
}
|
}
|
||||||
|
|
||||||
// StatusCodeError creates an instance of [HttpError] filled with information from the response
|
// StatusCodeError creates an HttpError from an http.Response with a non-successful status code.
|
||||||
|
// It reads the response body and captures all relevant information for debugging.
|
||||||
|
//
|
||||||
|
// The function:
|
||||||
|
// - Reads and stores the response body
|
||||||
|
// - Clones the response headers
|
||||||
|
// - Captures the request URL
|
||||||
|
// - Creates a comprehensive error with all this information
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - resp: The HTTP response with a non-successful status code
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - An error (specifically *HttpError) with detailed information
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// if !isValidStatus(response) {
|
||||||
|
// err := StatusCodeError(response)
|
||||||
|
// return err
|
||||||
|
// }
|
||||||
func StatusCodeError(resp *H.Response) error {
|
func StatusCodeError(resp *H.Response) error {
|
||||||
// read the body
|
// read the body
|
||||||
bodyRdr := GetBody(resp)
|
bodyRdr := GetBody(resp)
|
||||||
|
|||||||
@@ -16,12 +16,18 @@
|
|||||||
package http
|
package http
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
|
"io"
|
||||||
|
H "net/http"
|
||||||
|
"net/url"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
E "github.com/IBM/fp-go/v2/either"
|
E "github.com/IBM/fp-go/v2/either"
|
||||||
F "github.com/IBM/fp-go/v2/function"
|
F "github.com/IBM/fp-go/v2/function"
|
||||||
C "github.com/IBM/fp-go/v2/http/content"
|
C "github.com/IBM/fp-go/v2/http/content"
|
||||||
|
P "github.com/IBM/fp-go/v2/pair"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
func NoError[A any](t *testing.T) func(E.Either[error, A]) bool {
|
func NoError[A any](t *testing.T) func(E.Either[error, A]) bool {
|
||||||
@@ -37,21 +43,351 @@ func Error[A any](t *testing.T) func(E.Either[error, A]) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestValidateJsonContentTypeString(t *testing.T) {
|
func TestValidateJsonContentTypeString(t *testing.T) {
|
||||||
|
|
||||||
res := F.Pipe1(
|
res := F.Pipe1(
|
||||||
validateJSONContentTypeString(C.JSON),
|
validateJSONContentTypeString(C.JSON),
|
||||||
NoError[ParsedMediaType](t),
|
NoError[ParsedMediaType](t),
|
||||||
)
|
)
|
||||||
|
|
||||||
assert.True(t, res)
|
assert.True(t, res)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestValidateInvalidJsonContentTypeString(t *testing.T) {
|
func TestValidateInvalidJsonContentTypeString(t *testing.T) {
|
||||||
|
|
||||||
res := F.Pipe1(
|
res := F.Pipe1(
|
||||||
validateJSONContentTypeString("application/xml"),
|
validateJSONContentTypeString("application/xml"),
|
||||||
Error[ParsedMediaType](t),
|
Error[ParsedMediaType](t),
|
||||||
)
|
)
|
||||||
|
|
||||||
assert.True(t, res)
|
assert.True(t, res)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestParseMediaType tests parsing valid media types
|
||||||
|
func TestParseMediaType(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
mediaType string
|
||||||
|
wantType string
|
||||||
|
wantParam map[string]string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "simple JSON",
|
||||||
|
mediaType: "application/json",
|
||||||
|
wantType: "application/json",
|
||||||
|
wantParam: map[string]string{},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "JSON with charset",
|
||||||
|
mediaType: "application/json; charset=utf-8",
|
||||||
|
wantType: "application/json",
|
||||||
|
wantParam: map[string]string{"charset": "utf-8"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "HTML with charset",
|
||||||
|
mediaType: "text/html; charset=iso-8859-1",
|
||||||
|
wantType: "text/html",
|
||||||
|
wantParam: map[string]string{"charset": "iso-8859-1"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "multipart with boundary",
|
||||||
|
mediaType: "multipart/form-data; boundary=----WebKitFormBoundary",
|
||||||
|
wantType: "multipart/form-data",
|
||||||
|
wantParam: map[string]string{"boundary": "----WebKitFormBoundary"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result := ParseMediaType(tt.mediaType)
|
||||||
|
require.True(t, E.IsRight(result), "ParseMediaType should succeed")
|
||||||
|
|
||||||
|
parsed := E.GetOrElse(func(error) ParsedMediaType {
|
||||||
|
return P.MakePair("", map[string]string{})
|
||||||
|
})(result)
|
||||||
|
mediaType := P.Head(parsed)
|
||||||
|
params := P.Tail(parsed)
|
||||||
|
|
||||||
|
assert.Equal(t, tt.wantType, mediaType)
|
||||||
|
assert.Equal(t, tt.wantParam, params)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestParseMediaTypeInvalid tests parsing invalid media types
|
||||||
|
func TestParseMediaTypeInvalid(t *testing.T) {
|
||||||
|
result := ParseMediaType("invalid media type")
|
||||||
|
assert.True(t, E.IsLeft(result), "ParseMediaType should fail for invalid input")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHttpErrorMethods tests all HttpError methods
|
||||||
|
func TestHttpErrorMethods(t *testing.T) {
|
||||||
|
testURL, _ := url.Parse("https://example.com/api/test")
|
||||||
|
headers := make(H.Header)
|
||||||
|
headers.Set("Content-Type", "application/json")
|
||||||
|
headers.Set("X-Custom", "value")
|
||||||
|
body := []byte(`{"error": "not found"}`)
|
||||||
|
|
||||||
|
httpErr := &HttpError{
|
||||||
|
statusCode: 404,
|
||||||
|
headers: headers,
|
||||||
|
body: body,
|
||||||
|
url: testURL,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test StatusCode
|
||||||
|
assert.Equal(t, 404, httpErr.StatusCode())
|
||||||
|
|
||||||
|
// Test Headers
|
||||||
|
returnedHeaders := httpErr.Headers()
|
||||||
|
assert.Equal(t, "application/json", returnedHeaders.Get("Content-Type"))
|
||||||
|
assert.Equal(t, "value", returnedHeaders.Get("X-Custom"))
|
||||||
|
|
||||||
|
// Test Body
|
||||||
|
assert.Equal(t, body, httpErr.Body())
|
||||||
|
assert.Equal(t, `{"error": "not found"}`, string(httpErr.Body()))
|
||||||
|
|
||||||
|
// Test URL
|
||||||
|
assert.Equal(t, testURL, httpErr.URL())
|
||||||
|
assert.Equal(t, "https://example.com/api/test", httpErr.URL().String())
|
||||||
|
|
||||||
|
// Test Error
|
||||||
|
errMsg := httpErr.Error()
|
||||||
|
assert.Contains(t, errMsg, "404")
|
||||||
|
assert.Contains(t, errMsg, "https://example.com/api/test")
|
||||||
|
|
||||||
|
// Test String
|
||||||
|
assert.Equal(t, errMsg, httpErr.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGetHeader tests the GetHeader function
|
||||||
|
func TestGetHeader(t *testing.T) {
|
||||||
|
resp := &H.Response{
|
||||||
|
Header: make(H.Header),
|
||||||
|
}
|
||||||
|
resp.Header.Set("Content-Type", "application/json")
|
||||||
|
resp.Header.Set("Authorization", "Bearer token")
|
||||||
|
|
||||||
|
headers := GetHeader(resp)
|
||||||
|
assert.Equal(t, "application/json", headers.Get("Content-Type"))
|
||||||
|
assert.Equal(t, "Bearer token", headers.Get("Authorization"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGetBody tests the GetBody function
|
||||||
|
func TestGetBody(t *testing.T) {
|
||||||
|
bodyContent := []byte("test body content")
|
||||||
|
resp := &H.Response{
|
||||||
|
Body: io.NopCloser(bytes.NewReader(bodyContent)),
|
||||||
|
}
|
||||||
|
|
||||||
|
body := GetBody(resp)
|
||||||
|
defer body.Close()
|
||||||
|
|
||||||
|
data, err := io.ReadAll(body)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, bodyContent, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestIsValidStatus tests the isValidStatus function
|
||||||
|
func TestIsValidStatus(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
statusCode int
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"200 OK", H.StatusOK, true},
|
||||||
|
{"201 Created", H.StatusCreated, true},
|
||||||
|
{"204 No Content", H.StatusNoContent, true},
|
||||||
|
{"299 (edge of 2xx)", 299, true},
|
||||||
|
{"300 Multiple Choices", H.StatusMultipleChoices, false},
|
||||||
|
{"301 Moved Permanently", H.StatusMovedPermanently, false},
|
||||||
|
{"400 Bad Request", H.StatusBadRequest, false},
|
||||||
|
{"404 Not Found", H.StatusNotFound, false},
|
||||||
|
{"500 Internal Server Error", H.StatusInternalServerError, false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
resp := &H.Response{StatusCode: tt.statusCode}
|
||||||
|
assert.Equal(t, tt.want, isValidStatus(resp))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestValidateResponse tests the ValidateResponse function
|
||||||
|
func TestValidateResponse(t *testing.T) {
|
||||||
|
t.Run("successful response", func(t *testing.T) {
|
||||||
|
resp := &H.Response{
|
||||||
|
StatusCode: H.StatusOK,
|
||||||
|
Header: make(H.Header),
|
||||||
|
}
|
||||||
|
|
||||||
|
result := ValidateResponse(resp)
|
||||||
|
assert.True(t, E.IsRight(result))
|
||||||
|
|
||||||
|
validResp := E.GetOrElse(func(error) *H.Response { return nil })(result)
|
||||||
|
assert.Equal(t, resp, validResp)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("error response", func(t *testing.T) {
|
||||||
|
testURL, _ := url.Parse("https://example.com/test")
|
||||||
|
resp := &H.Response{
|
||||||
|
StatusCode: H.StatusNotFound,
|
||||||
|
Header: make(H.Header),
|
||||||
|
Body: io.NopCloser(bytes.NewReader([]byte("not found"))),
|
||||||
|
Request: &H.Request{URL: testURL},
|
||||||
|
}
|
||||||
|
|
||||||
|
result := ValidateResponse(resp)
|
||||||
|
assert.True(t, E.IsLeft(result))
|
||||||
|
|
||||||
|
// Extract error using Fold
|
||||||
|
var httpErr *HttpError
|
||||||
|
E.Fold(
|
||||||
|
func(err error) *H.Response {
|
||||||
|
var ok bool
|
||||||
|
httpErr, ok = err.(*HttpError)
|
||||||
|
require.True(t, ok, "error should be *HttpError")
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
func(r *H.Response) *H.Response { return r },
|
||||||
|
)(result)
|
||||||
|
assert.Equal(t, 404, httpErr.StatusCode())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestStatusCodeError tests the StatusCodeError function
|
||||||
|
func TestStatusCodeError(t *testing.T) {
|
||||||
|
testURL, _ := url.Parse("https://api.example.com/users/123")
|
||||||
|
bodyContent := []byte(`{"error": "user not found"}`)
|
||||||
|
|
||||||
|
headers := make(H.Header)
|
||||||
|
headers.Set("Content-Type", "application/json")
|
||||||
|
headers.Set("X-Request-ID", "abc123")
|
||||||
|
|
||||||
|
resp := &H.Response{
|
||||||
|
StatusCode: H.StatusNotFound,
|
||||||
|
Header: headers,
|
||||||
|
Body: io.NopCloser(bytes.NewReader(bodyContent)),
|
||||||
|
Request: &H.Request{URL: testURL},
|
||||||
|
}
|
||||||
|
|
||||||
|
err := StatusCodeError(resp)
|
||||||
|
require.Error(t, err)
|
||||||
|
|
||||||
|
httpErr, ok := err.(*HttpError)
|
||||||
|
require.True(t, ok, "error should be *HttpError")
|
||||||
|
|
||||||
|
// Verify all fields
|
||||||
|
assert.Equal(t, 404, httpErr.StatusCode())
|
||||||
|
assert.Equal(t, testURL, httpErr.URL())
|
||||||
|
assert.Equal(t, bodyContent, httpErr.Body())
|
||||||
|
|
||||||
|
// Verify headers are cloned
|
||||||
|
returnedHeaders := httpErr.Headers()
|
||||||
|
assert.Equal(t, "application/json", returnedHeaders.Get("Content-Type"))
|
||||||
|
assert.Equal(t, "abc123", returnedHeaders.Get("X-Request-ID"))
|
||||||
|
|
||||||
|
// Verify error message
|
||||||
|
errMsg := httpErr.Error()
|
||||||
|
assert.Contains(t, errMsg, "404")
|
||||||
|
assert.Contains(t, errMsg, "https://api.example.com/users/123")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestValidateJSONResponse tests the ValidateJSONResponse function
|
||||||
|
func TestValidateJSONResponse(t *testing.T) {
|
||||||
|
t.Run("valid JSON response", func(t *testing.T) {
|
||||||
|
resp := &H.Response{
|
||||||
|
StatusCode: H.StatusOK,
|
||||||
|
Header: make(H.Header),
|
||||||
|
}
|
||||||
|
resp.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
result := ValidateJSONResponse(resp)
|
||||||
|
assert.True(t, E.IsRight(result), "should accept valid JSON response")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("JSON with charset", func(t *testing.T) {
|
||||||
|
resp := &H.Response{
|
||||||
|
StatusCode: H.StatusOK,
|
||||||
|
Header: make(H.Header),
|
||||||
|
}
|
||||||
|
resp.Header.Set("Content-Type", "application/json; charset=utf-8")
|
||||||
|
|
||||||
|
result := ValidateJSONResponse(resp)
|
||||||
|
assert.True(t, E.IsRight(result), "should accept JSON with charset")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("JSON variant (hal+json)", func(t *testing.T) {
|
||||||
|
resp := &H.Response{
|
||||||
|
StatusCode: H.StatusOK,
|
||||||
|
Header: make(H.Header),
|
||||||
|
}
|
||||||
|
resp.Header.Set("Content-Type", "application/hal+json")
|
||||||
|
|
||||||
|
result := ValidateJSONResponse(resp)
|
||||||
|
assert.True(t, E.IsRight(result), "should accept JSON variants")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("non-JSON content type", func(t *testing.T) {
|
||||||
|
resp := &H.Response{
|
||||||
|
StatusCode: H.StatusOK,
|
||||||
|
Header: make(H.Header),
|
||||||
|
}
|
||||||
|
resp.Header.Set("Content-Type", "text/html")
|
||||||
|
|
||||||
|
result := ValidateJSONResponse(resp)
|
||||||
|
assert.True(t, E.IsLeft(result), "should reject non-JSON content type")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("missing Content-Type header", func(t *testing.T) {
|
||||||
|
resp := &H.Response{
|
||||||
|
StatusCode: H.StatusOK,
|
||||||
|
Header: make(H.Header),
|
||||||
|
}
|
||||||
|
|
||||||
|
result := ValidateJSONResponse(resp)
|
||||||
|
assert.True(t, E.IsLeft(result), "should reject missing Content-Type")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("valid JSON with error status code", func(t *testing.T) {
|
||||||
|
// Note: ValidateJSONResponse only validates Content-Type, not status code
|
||||||
|
// It wraps the response in Right(response) first, then validates headers
|
||||||
|
resp := &H.Response{
|
||||||
|
StatusCode: H.StatusInternalServerError,
|
||||||
|
Header: make(H.Header),
|
||||||
|
}
|
||||||
|
resp.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
result := ValidateJSONResponse(resp)
|
||||||
|
// This actually succeeds because ValidateJSONResponse doesn't check status
|
||||||
|
assert.True(t, E.IsRight(result), "ValidateJSONResponse only checks Content-Type, not status")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFullResponseAccessors tests Response and Body accessors
|
||||||
|
func TestFullResponseAccessors(t *testing.T) {
|
||||||
|
resp := &H.Response{
|
||||||
|
StatusCode: H.StatusOK,
|
||||||
|
Header: make(H.Header),
|
||||||
|
}
|
||||||
|
resp.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
bodyContent := []byte(`{"message": "success"}`)
|
||||||
|
fullResp := P.MakePair(resp, bodyContent)
|
||||||
|
|
||||||
|
// Test Response accessor
|
||||||
|
extractedResp := Response(fullResp)
|
||||||
|
assert.Equal(t, resp, extractedResp)
|
||||||
|
assert.Equal(t, H.StatusOK, extractedResp.StatusCode)
|
||||||
|
|
||||||
|
// Test Body accessor
|
||||||
|
extractedBody := Body(fullResp)
|
||||||
|
assert.Equal(t, bodyContent, extractedBody)
|
||||||
|
assert.Equal(t, `{"message": "success"}`, string(extractedBody))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHeaderContentTypeConstant tests the HeaderContentType constant
|
||||||
|
func TestHeaderContentTypeConstant(t *testing.T) {
|
||||||
|
assert.Equal(t, "Content-Type", HeaderContentType)
|
||||||
|
|
||||||
|
// Test usage with http.Header
|
||||||
|
headers := make(H.Header)
|
||||||
|
headers.Set(HeaderContentType, "application/json")
|
||||||
|
assert.Equal(t, "application/json", headers.Get(HeaderContentType))
|
||||||
|
}
|
||||||
|
|||||||
5
v2/logging/coverage.out
Normal file
5
v2/logging/coverage.out
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
mode: set
|
||||||
|
github.com/IBM/fp-go/v2/logging/logger.go:54.92,55.22 1 1
|
||||||
|
github.com/IBM/fp-go/v2/logging/logger.go:56.9,58.32 2 1
|
||||||
|
github.com/IBM/fp-go/v2/logging/logger.go:59.9,61.34 2 1
|
||||||
|
github.com/IBM/fp-go/v2/logging/logger.go:62.10,63.46 1 1
|
||||||
@@ -13,12 +13,44 @@
|
|||||||
// See the License for the specific language governing permissions and
|
// See the License for the specific language governing permissions and
|
||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
|
|
||||||
|
// Package logging provides utilities for creating logging callbacks from standard log.Logger instances.
|
||||||
|
// It offers a convenient way to configure logging for functional programming patterns where separate
|
||||||
|
// loggers for success and error cases are needed.
|
||||||
package logging
|
package logging
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"log"
|
"log"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// LoggingCallbacks creates a pair of logging callback functions from the provided loggers.
|
||||||
|
// It returns two functions that can be used for logging messages, typically one for success
|
||||||
|
// cases and one for error cases.
|
||||||
|
//
|
||||||
|
// The behavior depends on the number of loggers provided:
|
||||||
|
// - 0 loggers: Returns two callbacks using log.Default() for both success and error logging
|
||||||
|
// - 1 logger: Returns two callbacks both using the provided logger
|
||||||
|
// - 2+ loggers: Returns callbacks using the first logger for success and second for errors
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - loggers: Variable number of *log.Logger instances (0, 1, or more)
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - First function: Callback for success/info logging (signature: func(string, ...any))
|
||||||
|
// - Second function: Callback for error logging (signature: func(string, ...any))
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// // Using default logger for both
|
||||||
|
// infoLog, errLog := LoggingCallbacks()
|
||||||
|
//
|
||||||
|
// // Using custom logger for both
|
||||||
|
// customLogger := log.New(os.Stdout, "APP: ", log.LstdFlags)
|
||||||
|
// infoLog, errLog := LoggingCallbacks(customLogger)
|
||||||
|
//
|
||||||
|
// // Using separate loggers for info and errors
|
||||||
|
// infoLogger := log.New(os.Stdout, "INFO: ", log.LstdFlags)
|
||||||
|
// errorLogger := log.New(os.Stderr, "ERROR: ", log.LstdFlags)
|
||||||
|
// infoLog, errLog := LoggingCallbacks(infoLogger, errorLogger)
|
||||||
func LoggingCallbacks(loggers ...*log.Logger) (func(string, ...any), func(string, ...any)) {
|
func LoggingCallbacks(loggers ...*log.Logger) (func(string, ...any), func(string, ...any)) {
|
||||||
switch len(loggers) {
|
switch len(loggers) {
|
||||||
case 0:
|
case 0:
|
||||||
|
|||||||
288
v2/logging/logger_test.go
Normal file
288
v2/logging/logger_test.go
Normal file
@@ -0,0 +1,288 @@
|
|||||||
|
// Copyright (c) 2023 - 2025 IBM Corp.
|
||||||
|
// All rights reserved.
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
package logging
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"log"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestLoggingCallbacks_NoLoggers tests the case when no loggers are provided.
|
||||||
|
// It should return two callbacks using the default logger.
|
||||||
|
func TestLoggingCallbacks_NoLoggers(t *testing.T) {
|
||||||
|
infoLog, errLog := LoggingCallbacks()
|
||||||
|
|
||||||
|
if infoLog == nil {
|
||||||
|
t.Error("Expected infoLog to be non-nil")
|
||||||
|
}
|
||||||
|
if errLog == nil {
|
||||||
|
t.Error("Expected errLog to be non-nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify both callbacks work
|
||||||
|
var buf bytes.Buffer
|
||||||
|
log.SetOutput(&buf)
|
||||||
|
defer log.SetOutput(nil)
|
||||||
|
|
||||||
|
infoLog("test info: %s", "message")
|
||||||
|
if !strings.Contains(buf.String(), "test info: message") {
|
||||||
|
t.Errorf("Expected log to contain 'test info: message', got: %s", buf.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
buf.Reset()
|
||||||
|
errLog("test error: %s", "message")
|
||||||
|
if !strings.Contains(buf.String(), "test error: message") {
|
||||||
|
t.Errorf("Expected log to contain 'test error: message', got: %s", buf.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLoggingCallbacks_OneLogger tests the case when one logger is provided.
|
||||||
|
// Both callbacks should use the same logger.
|
||||||
|
func TestLoggingCallbacks_OneLogger(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
logger := log.New(&buf, "TEST: ", 0)
|
||||||
|
|
||||||
|
infoLog, errLog := LoggingCallbacks(logger)
|
||||||
|
|
||||||
|
if infoLog == nil {
|
||||||
|
t.Error("Expected infoLog to be non-nil")
|
||||||
|
}
|
||||||
|
if errLog == nil {
|
||||||
|
t.Error("Expected errLog to be non-nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test info callback
|
||||||
|
infoLog("info message: %d", 42)
|
||||||
|
output := buf.String()
|
||||||
|
if !strings.Contains(output, "TEST: info message: 42") {
|
||||||
|
t.Errorf("Expected log to contain 'TEST: info message: 42', got: %s", output)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test error callback uses same logger
|
||||||
|
buf.Reset()
|
||||||
|
errLog("error message: %s", "failed")
|
||||||
|
output = buf.String()
|
||||||
|
if !strings.Contains(output, "TEST: error message: failed") {
|
||||||
|
t.Errorf("Expected log to contain 'TEST: error message: failed', got: %s", output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLoggingCallbacks_TwoLoggers tests the case when two loggers are provided.
|
||||||
|
// First callback should use first logger, second callback should use second logger.
|
||||||
|
func TestLoggingCallbacks_TwoLoggers(t *testing.T) {
|
||||||
|
var infoBuf, errBuf bytes.Buffer
|
||||||
|
infoLogger := log.New(&infoBuf, "INFO: ", 0)
|
||||||
|
errorLogger := log.New(&errBuf, "ERROR: ", 0)
|
||||||
|
|
||||||
|
infoLog, errLog := LoggingCallbacks(infoLogger, errorLogger)
|
||||||
|
|
||||||
|
if infoLog == nil {
|
||||||
|
t.Error("Expected infoLog to be non-nil")
|
||||||
|
}
|
||||||
|
if errLog == nil {
|
||||||
|
t.Error("Expected errLog to be non-nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test info callback uses first logger
|
||||||
|
infoLog("success: %s", "operation completed")
|
||||||
|
infoOutput := infoBuf.String()
|
||||||
|
if !strings.Contains(infoOutput, "INFO: success: operation completed") {
|
||||||
|
t.Errorf("Expected info log to contain 'INFO: success: operation completed', got: %s", infoOutput)
|
||||||
|
}
|
||||||
|
if errBuf.Len() != 0 {
|
||||||
|
t.Errorf("Expected error buffer to be empty, got: %s", errBuf.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test error callback uses second logger
|
||||||
|
errLog("failure: %s", "operation failed")
|
||||||
|
errOutput := errBuf.String()
|
||||||
|
if !strings.Contains(errOutput, "ERROR: failure: operation failed") {
|
||||||
|
t.Errorf("Expected error log to contain 'ERROR: failure: operation failed', got: %s", errOutput)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLoggingCallbacks_MultipleLoggers tests the case when more than two loggers are provided.
|
||||||
|
// Should use first two loggers and ignore the rest.
|
||||||
|
func TestLoggingCallbacks_MultipleLoggers(t *testing.T) {
|
||||||
|
var buf1, buf2, buf3 bytes.Buffer
|
||||||
|
logger1 := log.New(&buf1, "LOG1: ", 0)
|
||||||
|
logger2 := log.New(&buf2, "LOG2: ", 0)
|
||||||
|
logger3 := log.New(&buf3, "LOG3: ", 0)
|
||||||
|
|
||||||
|
infoLog, errLog := LoggingCallbacks(logger1, logger2, logger3)
|
||||||
|
|
||||||
|
if infoLog == nil {
|
||||||
|
t.Error("Expected infoLog to be non-nil")
|
||||||
|
}
|
||||||
|
if errLog == nil {
|
||||||
|
t.Error("Expected errLog to be non-nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test that first logger is used for info
|
||||||
|
infoLog("message 1")
|
||||||
|
if !strings.Contains(buf1.String(), "LOG1: message 1") {
|
||||||
|
t.Errorf("Expected first logger to be used, got: %s", buf1.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test that second logger is used for error
|
||||||
|
errLog("message 2")
|
||||||
|
if !strings.Contains(buf2.String(), "LOG2: message 2") {
|
||||||
|
t.Errorf("Expected second logger to be used, got: %s", buf2.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test that third logger is not used
|
||||||
|
if buf3.Len() != 0 {
|
||||||
|
t.Errorf("Expected third logger to not be used, got: %s", buf3.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLoggingCallbacks_FormattingWithMultipleArgs tests that formatting works correctly
|
||||||
|
// with multiple arguments.
|
||||||
|
func TestLoggingCallbacks_FormattingWithMultipleArgs(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
logger := log.New(&buf, "", 0)
|
||||||
|
|
||||||
|
infoLog, _ := LoggingCallbacks(logger)
|
||||||
|
|
||||||
|
infoLog("test %s %d %v", "string", 123, true)
|
||||||
|
output := buf.String()
|
||||||
|
if !strings.Contains(output, "test string 123 true") {
|
||||||
|
t.Errorf("Expected formatted output 'test string 123 true', got: %s", output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLoggingCallbacks_NoFormatting tests logging without format specifiers.
|
||||||
|
func TestLoggingCallbacks_NoFormatting(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
logger := log.New(&buf, "PREFIX: ", 0)
|
||||||
|
|
||||||
|
infoLog, _ := LoggingCallbacks(logger)
|
||||||
|
|
||||||
|
infoLog("simple message")
|
||||||
|
output := buf.String()
|
||||||
|
if !strings.Contains(output, "PREFIX: simple message") {
|
||||||
|
t.Errorf("Expected 'PREFIX: simple message', got: %s", output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLoggingCallbacks_EmptyMessage tests logging with empty message.
|
||||||
|
func TestLoggingCallbacks_EmptyMessage(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
logger := log.New(&buf, "", 0)
|
||||||
|
|
||||||
|
infoLog, _ := LoggingCallbacks(logger)
|
||||||
|
|
||||||
|
infoLog("")
|
||||||
|
output := buf.String()
|
||||||
|
// Should still produce output (newline at minimum)
|
||||||
|
if len(output) == 0 {
|
||||||
|
t.Error("Expected some output even with empty message")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLoggingCallbacks_NilLogger tests behavior when nil logger is passed.
|
||||||
|
// This tests edge case handling.
|
||||||
|
func TestLoggingCallbacks_NilLogger(t *testing.T) {
|
||||||
|
// This should not panic
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
t.Errorf("LoggingCallbacks panicked with nil logger: %v", r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
infoLog, errLog := LoggingCallbacks(nil)
|
||||||
|
|
||||||
|
// The callbacks should still be created
|
||||||
|
if infoLog == nil {
|
||||||
|
t.Error("Expected infoLog to be non-nil even with nil logger")
|
||||||
|
}
|
||||||
|
if errLog == nil {
|
||||||
|
t.Error("Expected errLog to be non-nil even with nil logger")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLoggingCallbacks_ConsecutiveCalls tests that callbacks can be called multiple times.
|
||||||
|
func TestLoggingCallbacks_ConsecutiveCalls(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
logger := log.New(&buf, "", 0)
|
||||||
|
|
||||||
|
infoLog, errLog := LoggingCallbacks(logger)
|
||||||
|
|
||||||
|
// Multiple calls to info
|
||||||
|
infoLog("call 1")
|
||||||
|
infoLog("call 2")
|
||||||
|
infoLog("call 3")
|
||||||
|
|
||||||
|
output := buf.String()
|
||||||
|
if !strings.Contains(output, "call 1") || !strings.Contains(output, "call 2") || !strings.Contains(output, "call 3") {
|
||||||
|
t.Errorf("Expected all three calls to be logged, got: %s", output)
|
||||||
|
}
|
||||||
|
|
||||||
|
buf.Reset()
|
||||||
|
|
||||||
|
// Multiple calls to error
|
||||||
|
errLog("error 1")
|
||||||
|
errLog("error 2")
|
||||||
|
|
||||||
|
output = buf.String()
|
||||||
|
if !strings.Contains(output, "error 1") || !strings.Contains(output, "error 2") {
|
||||||
|
t.Errorf("Expected both error calls to be logged, got: %s", output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// BenchmarkLoggingCallbacks_NoLoggers benchmarks the no-logger case.
|
||||||
|
func BenchmarkLoggingCallbacks_NoLoggers(b *testing.B) {
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
LoggingCallbacks()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// BenchmarkLoggingCallbacks_OneLogger benchmarks the single-logger case.
|
||||||
|
func BenchmarkLoggingCallbacks_OneLogger(b *testing.B) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
logger := log.New(&buf, "", 0)
|
||||||
|
|
||||||
|
b.ResetTimer()
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
LoggingCallbacks(logger)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// BenchmarkLoggingCallbacks_TwoLoggers benchmarks the two-logger case.
|
||||||
|
func BenchmarkLoggingCallbacks_TwoLoggers(b *testing.B) {
|
||||||
|
var buf1, buf2 bytes.Buffer
|
||||||
|
logger1 := log.New(&buf1, "", 0)
|
||||||
|
logger2 := log.New(&buf2, "", 0)
|
||||||
|
|
||||||
|
b.ResetTimer()
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
LoggingCallbacks(logger1, logger2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// BenchmarkLoggingCallbacks_Logging benchmarks actual logging operations.
|
||||||
|
func BenchmarkLoggingCallbacks_Logging(b *testing.B) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
logger := log.New(&buf, "", 0)
|
||||||
|
infoLog, _ := LoggingCallbacks(logger)
|
||||||
|
|
||||||
|
b.ResetTimer()
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
infoLog("benchmark message %d", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
// See the License for the specific language governing permissions and
|
// See the License for the specific language governing permissions and
|
||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
|
|
||||||
// Iso is an optic which converts elements of type `S` into elements of type `A` without loss.
|
// Package iso provides isomorphisms - bidirectional transformations between types without loss of information.
|
||||||
package iso
|
package iso
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -21,21 +21,127 @@ import (
|
|||||||
F "github.com/IBM/fp-go/v2/function"
|
F "github.com/IBM/fp-go/v2/function"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Iso represents an isomorphism between types S and A.
|
||||||
|
// An isomorphism is a bidirectional transformation that converts between two types
|
||||||
|
// without any loss of information. It consists of two functions that are inverses
|
||||||
|
// of each other.
|
||||||
|
//
|
||||||
|
// Type Parameters:
|
||||||
|
// - S: The source type
|
||||||
|
// - A: The target type
|
||||||
|
//
|
||||||
|
// Fields:
|
||||||
|
// - Get: Converts from S to A
|
||||||
|
// - ReverseGet: Converts from A back to S
|
||||||
|
//
|
||||||
|
// Laws:
|
||||||
|
// An Iso must satisfy the round-trip laws:
|
||||||
|
// 1. ReverseGet(Get(s)) == s for all s: S
|
||||||
|
// 2. Get(ReverseGet(a)) == a for all a: A
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// // Isomorphism between Celsius and Fahrenheit
|
||||||
|
// tempIso := Iso[float64, float64]{
|
||||||
|
// Get: func(c float64) float64 { return c*9/5 + 32 },
|
||||||
|
// ReverseGet: func(f float64) float64 { return (f - 32) * 5 / 9 },
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// fahrenheit := tempIso.Get(20.0) // 68.0
|
||||||
|
// celsius := tempIso.ReverseGet(68.0) // 20.0
|
||||||
type Iso[S, A any] struct {
|
type Iso[S, A any] struct {
|
||||||
Get func(s S) A
|
// Get converts a value from the source type S to the target type A.
|
||||||
|
Get func(s S) A
|
||||||
|
|
||||||
|
// ReverseGet converts a value from the target type A back to the source type S.
|
||||||
|
// This is the inverse of Get.
|
||||||
ReverseGet func(a A) S
|
ReverseGet func(a A) S
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MakeIso constructs an isomorphism from two functions.
|
||||||
|
// The functions should be inverses of each other to satisfy the isomorphism laws.
|
||||||
|
//
|
||||||
|
// Type Parameters:
|
||||||
|
// - S: The source type
|
||||||
|
// - A: The target type
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - get: Function to convert from S to A
|
||||||
|
// - reverse: Function to convert from A to S (inverse of get)
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - An Iso[S, A] that uses the provided functions
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// // Create an isomorphism between string and []byte
|
||||||
|
// stringBytesIso := MakeIso(
|
||||||
|
// func(s string) []byte { return []byte(s) },
|
||||||
|
// func(b []byte) string { return string(b) },
|
||||||
|
// )
|
||||||
|
//
|
||||||
|
// bytes := stringBytesIso.Get("hello") // []byte("hello")
|
||||||
|
// str := stringBytesIso.ReverseGet([]byte("hi")) // "hi"
|
||||||
func MakeIso[S, A any](get func(S) A, reverse func(A) S) Iso[S, A] {
|
func MakeIso[S, A any](get func(S) A, reverse func(A) S) Iso[S, A] {
|
||||||
return Iso[S, A]{Get: get, ReverseGet: reverse}
|
return Iso[S, A]{Get: get, ReverseGet: reverse}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Id returns an iso implementing the identity operation
|
// Id returns an identity isomorphism that performs no transformation.
|
||||||
|
// Both Get and ReverseGet are the identity function.
|
||||||
|
//
|
||||||
|
// Type Parameters:
|
||||||
|
// - S: The type for both source and target
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - An Iso[S, S] where Get and ReverseGet are both identity functions
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// idIso := Id[int]()
|
||||||
|
// value := idIso.Get(42) // 42
|
||||||
|
// same := idIso.ReverseGet(42) // 42
|
||||||
|
//
|
||||||
|
// Use cases:
|
||||||
|
// - As a starting point for isomorphism composition
|
||||||
|
// - When you need an isomorphism but don't want to transform the value
|
||||||
|
// - In generic code that requires an isomorphism parameter
|
||||||
func Id[S any]() Iso[S, S] {
|
func Id[S any]() Iso[S, S] {
|
||||||
return MakeIso(F.Identity[S], F.Identity[S])
|
return MakeIso(F.Identity[S], F.Identity[S])
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compose combines an ISO with another ISO
|
// Compose combines two isomorphisms to create a new isomorphism.
|
||||||
|
// Given Iso[S, A] and Iso[A, B], creates Iso[S, B].
|
||||||
|
// The resulting isomorphism first applies the outer iso (S → A),
|
||||||
|
// then the inner iso (A → B).
|
||||||
|
//
|
||||||
|
// Type Parameters:
|
||||||
|
// - S: The outermost source type
|
||||||
|
// - A: The intermediate type
|
||||||
|
// - B: The innermost target type
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - ab: The inner isomorphism (A → B)
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - A function that takes the outer isomorphism (S → A) and returns the composed isomorphism (S → B)
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// metersToKm := MakeIso(
|
||||||
|
// func(m float64) float64 { return m / 1000 },
|
||||||
|
// func(km float64) float64 { return km * 1000 },
|
||||||
|
// )
|
||||||
|
//
|
||||||
|
// kmToMiles := MakeIso(
|
||||||
|
// func(km float64) float64 { return km * 0.621371 },
|
||||||
|
// func(mi float64) float64 { return mi / 0.621371 },
|
||||||
|
// )
|
||||||
|
//
|
||||||
|
// // Compose: meters → kilometers → miles
|
||||||
|
// metersToMiles := F.Pipe1(metersToKm, Compose[float64](kmToMiles))
|
||||||
|
//
|
||||||
|
// miles := metersToMiles.Get(5000) // ~3.11 miles
|
||||||
|
// meters := metersToMiles.ReverseGet(3.11) // ~5000 meters
|
||||||
func Compose[S, A, B any](ab Iso[A, B]) func(Iso[S, A]) Iso[S, B] {
|
func Compose[S, A, B any](ab Iso[A, B]) func(Iso[S, A]) Iso[S, B] {
|
||||||
return func(sa Iso[S, A]) Iso[S, B] {
|
return func(sa Iso[S, A]) Iso[S, B] {
|
||||||
return MakeIso(
|
return MakeIso(
|
||||||
@@ -45,7 +151,31 @@ func Compose[S, A, B any](ab Iso[A, B]) func(Iso[S, A]) Iso[S, B] {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reverse changes the order of parameters for an iso
|
// Reverse swaps the direction of an isomorphism.
|
||||||
|
// Given Iso[S, A], creates Iso[A, S] where Get and ReverseGet are swapped.
|
||||||
|
//
|
||||||
|
// Type Parameters:
|
||||||
|
// - S: The original source type (becomes target)
|
||||||
|
// - A: The original target type (becomes source)
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - sa: The isomorphism to reverse
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - An Iso[A, S] with Get and ReverseGet swapped
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// celsiusToFahrenheit := MakeIso(
|
||||||
|
// func(c float64) float64 { return c*9/5 + 32 },
|
||||||
|
// func(f float64) float64 { return (f - 32) * 5 / 9 },
|
||||||
|
// )
|
||||||
|
//
|
||||||
|
// // Reverse to get Fahrenheit to Celsius
|
||||||
|
// fahrenheitToCelsius := Reverse(celsiusToFahrenheit)
|
||||||
|
//
|
||||||
|
// celsius := fahrenheitToCelsius.Get(68.0) // 20.0
|
||||||
|
// fahrenheit := fahrenheitToCelsius.ReverseGet(20.0) // 68.0
|
||||||
func Reverse[S, A any](sa Iso[S, A]) Iso[A, S] {
|
func Reverse[S, A any](sa Iso[S, A]) Iso[A, S] {
|
||||||
return MakeIso(
|
return MakeIso(
|
||||||
sa.ReverseGet,
|
sa.ReverseGet,
|
||||||
@@ -53,6 +183,8 @@ func Reverse[S, A any](sa Iso[S, A]) Iso[A, S] {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// modify is an internal helper that applies a transformation function through an isomorphism.
|
||||||
|
// It converts S to A, applies the function, then converts back to S.
|
||||||
func modify[FCT ~func(A) A, S, A any](f FCT, sa Iso[S, A], s S) S {
|
func modify[FCT ~func(A) A, S, A any](f FCT, sa Iso[S, A], s S) S {
|
||||||
return F.Pipe3(
|
return F.Pipe3(
|
||||||
s,
|
s,
|
||||||
@@ -62,35 +194,166 @@ func modify[FCT ~func(A) A, S, A any](f FCT, sa Iso[S, A], s S) S {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Modify applies a transformation
|
// Modify creates a function that applies a transformation in the target space.
|
||||||
|
// It converts the source value to the target type, applies the transformation,
|
||||||
|
// then converts back to the source type.
|
||||||
|
//
|
||||||
|
// Type Parameters:
|
||||||
|
// - S: The source type
|
||||||
|
// - FCT: The transformation function type (A → A)
|
||||||
|
// - A: The target type
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - f: The transformation function to apply in the target space
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - A function that takes an Iso[S, A] and returns an endomorphism (S → S)
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// type Meters float64
|
||||||
|
// type Kilometers float64
|
||||||
|
//
|
||||||
|
// mToKm := MakeIso(
|
||||||
|
// func(m Meters) Kilometers { return Kilometers(m / 1000) },
|
||||||
|
// func(km Kilometers) Meters { return Meters(km * 1000) },
|
||||||
|
// )
|
||||||
|
//
|
||||||
|
// // Double the distance in kilometers, result in meters
|
||||||
|
// doubled := Modify[Meters](func(km Kilometers) Kilometers {
|
||||||
|
// return km * 2
|
||||||
|
// })(mToKm)(Meters(5000))
|
||||||
|
// // Result: Meters(10000)
|
||||||
func Modify[S any, FCT ~func(A) A, A any](f FCT) func(Iso[S, A]) EM.Endomorphism[S] {
|
func Modify[S any, FCT ~func(A) A, A any](f FCT) func(Iso[S, A]) EM.Endomorphism[S] {
|
||||||
return EM.Curry3(modify[FCT, S, A])(f)
|
return F.Curry3(modify[FCT, S, A])(f)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wrap wraps the value
|
// Unwrap extracts the target value from a source value using an isomorphism.
|
||||||
|
// This is a convenience function that applies the Get function of the isomorphism.
|
||||||
|
//
|
||||||
|
// Type Parameters:
|
||||||
|
// - A: The target type to extract
|
||||||
|
// - S: The source type
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - s: The source value to unwrap
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - A function that takes an Iso[S, A] and returns the unwrapped value of type A
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// type UserId int
|
||||||
|
//
|
||||||
|
// userIdIso := MakeIso(
|
||||||
|
// func(id UserId) int { return int(id) },
|
||||||
|
// func(i int) UserId { return UserId(i) },
|
||||||
|
// )
|
||||||
|
//
|
||||||
|
// rawId := Unwrap[int](UserId(42))(userIdIso) // 42
|
||||||
|
//
|
||||||
|
// Note: This function is also available as To for semantic clarity.
|
||||||
func Unwrap[A, S any](s S) func(Iso[S, A]) A {
|
func Unwrap[A, S any](s S) func(Iso[S, A]) A {
|
||||||
return func(sa Iso[S, A]) A {
|
return func(sa Iso[S, A]) A {
|
||||||
return sa.Get(s)
|
return sa.Get(s)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Unwrap unwraps the value
|
// Wrap wraps a target value into a source value using an isomorphism.
|
||||||
|
// This is a convenience function that applies the ReverseGet function of the isomorphism.
|
||||||
|
//
|
||||||
|
// Type Parameters:
|
||||||
|
// - S: The source type to wrap into
|
||||||
|
// - A: The target type
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - a: The target value to wrap
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - A function that takes an Iso[S, A] and returns the wrapped value of type S
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// type UserId int
|
||||||
|
//
|
||||||
|
// userIdIso := MakeIso(
|
||||||
|
// func(id UserId) int { return int(id) },
|
||||||
|
// func(i int) UserId { return UserId(i) },
|
||||||
|
// )
|
||||||
|
//
|
||||||
|
// userId := Wrap[UserId](42)(userIdIso) // UserId(42)
|
||||||
|
//
|
||||||
|
// Note: This function is also available as From for semantic clarity.
|
||||||
func Wrap[S, A any](a A) func(Iso[S, A]) S {
|
func Wrap[S, A any](a A) func(Iso[S, A]) S {
|
||||||
return func(sa Iso[S, A]) S {
|
return func(sa Iso[S, A]) S {
|
||||||
return sa.ReverseGet(a)
|
return sa.ReverseGet(a)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// From wraps the value
|
// To extracts the target value from a source value using an isomorphism.
|
||||||
|
// This is an alias for Unwrap, provided for semantic clarity when the
|
||||||
|
// direction of conversion is important.
|
||||||
|
//
|
||||||
|
// Type Parameters:
|
||||||
|
// - A: The target type to convert to
|
||||||
|
// - S: The source type
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - s: The source value to convert
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - A function that takes an Iso[S, A] and returns the converted value of type A
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// type Email string
|
||||||
|
// type ValidatedEmail struct{ value Email }
|
||||||
|
//
|
||||||
|
// emailIso := MakeIso(
|
||||||
|
// func(ve ValidatedEmail) Email { return ve.value },
|
||||||
|
// func(e Email) ValidatedEmail { return ValidatedEmail{value: e} },
|
||||||
|
// )
|
||||||
|
//
|
||||||
|
// // Convert to Email
|
||||||
|
// email := To[Email](ValidatedEmail{value: "user@example.com"})(emailIso)
|
||||||
|
// // "user@example.com"
|
||||||
func To[A, S any](s S) func(Iso[S, A]) A {
|
func To[A, S any](s S) func(Iso[S, A]) A {
|
||||||
return Unwrap[A, S](s)
|
return Unwrap[A, S](s)
|
||||||
}
|
}
|
||||||
|
|
||||||
// To unwraps the value
|
// From wraps a target value into a source value using an isomorphism.
|
||||||
|
// This is an alias for Wrap, provided for semantic clarity when the
|
||||||
|
// direction of conversion is important.
|
||||||
|
//
|
||||||
|
// Type Parameters:
|
||||||
|
// - S: The source type to convert from
|
||||||
|
// - A: The target type
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - a: The target value to convert
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - A function that takes an Iso[S, A] and returns the converted value of type S
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// type Email string
|
||||||
|
// type ValidatedEmail struct{ value Email }
|
||||||
|
//
|
||||||
|
// emailIso := MakeIso(
|
||||||
|
// func(ve ValidatedEmail) Email { return ve.value },
|
||||||
|
// func(e Email) ValidatedEmail { return ValidatedEmail{value: e} },
|
||||||
|
// )
|
||||||
|
//
|
||||||
|
// // Convert from Email
|
||||||
|
// validated := From[ValidatedEmail](Email("admin@example.com"))(emailIso)
|
||||||
|
// // ValidatedEmail{value: "admin@example.com"}
|
||||||
func From[S, A any](a A) func(Iso[S, A]) S {
|
func From[S, A any](a A) func(Iso[S, A]) S {
|
||||||
return Wrap[S](a)
|
return Wrap[S](a)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// imap is an internal helper that bidirectionally maps an isomorphism.
|
||||||
|
// It transforms both directions of the isomorphism using the provided functions.
|
||||||
func imap[S, A, B any](sa Iso[S, A], ab func(A) B, ba func(B) A) Iso[S, B] {
|
func imap[S, A, B any](sa Iso[S, A], ab func(A) B, ba func(B) A) Iso[S, B] {
|
||||||
return MakeIso(
|
return MakeIso(
|
||||||
F.Flow2(sa.Get, ab),
|
F.Flow2(sa.Get, ab),
|
||||||
@@ -98,7 +361,43 @@ func imap[S, A, B any](sa Iso[S, A], ab func(A) B, ba func(B) A) Iso[S, B] {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// IMap implements a bidirectional mapping of the transform
|
// IMap bidirectionally maps the target type of an isomorphism.
|
||||||
|
// Given Iso[S, A] and functions A → B and B → A, creates Iso[S, B].
|
||||||
|
// This allows you to transform both directions of an isomorphism.
|
||||||
|
//
|
||||||
|
// Type Parameters:
|
||||||
|
// - S: The source type (unchanged)
|
||||||
|
// - A: The original target type
|
||||||
|
// - B: The new target type
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - ab: Function to map from A to B
|
||||||
|
// - ba: Function to map from B to A (inverse of ab)
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - A function that transforms Iso[S, A] to Iso[S, B]
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// type Celsius float64
|
||||||
|
// type Kelvin float64
|
||||||
|
//
|
||||||
|
// celsiusIso := Id[Celsius]()
|
||||||
|
//
|
||||||
|
// // Create isomorphism to Kelvin
|
||||||
|
// celsiusToKelvin := F.Pipe1(
|
||||||
|
// celsiusIso,
|
||||||
|
// IMap(
|
||||||
|
// func(c Celsius) Kelvin { return Kelvin(c + 273.15) },
|
||||||
|
// func(k Kelvin) Celsius { return Celsius(k - 273.15) },
|
||||||
|
// ),
|
||||||
|
// )
|
||||||
|
//
|
||||||
|
// kelvin := celsiusToKelvin.Get(Celsius(20)) // 293.15 K
|
||||||
|
// celsius := celsiusToKelvin.ReverseGet(Kelvin(293.15)) // 20°C
|
||||||
|
//
|
||||||
|
// Note: The functions ab and ba must be inverses of each other to maintain
|
||||||
|
// the isomorphism laws.
|
||||||
func IMap[S, A, B any](ab func(A) B, ba func(B) A) func(Iso[S, A]) Iso[S, B] {
|
func IMap[S, A, B any](ab func(A) B, ba func(B) A) func(Iso[S, A]) Iso[S, B] {
|
||||||
return func(sa Iso[S, A]) Iso[S, B] {
|
return func(sa Iso[S, A]) Iso[S, B] {
|
||||||
return imap(sa, ab, ba)
|
return imap(sa, ab, ba)
|
||||||
|
|||||||
@@ -13,7 +13,6 @@
|
|||||||
// See the License for the specific language governing permissions and
|
// See the License for the specific language governing permissions and
|
||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
|
|
||||||
// Prism is an optic used to select part of a sum type.
|
|
||||||
package prism
|
package prism
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -23,40 +22,131 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type (
|
type (
|
||||||
// Prism is an optic used to select part of a sum type.
|
// Prism is an optic used to select part of a sum type (tagged union).
|
||||||
|
// It provides two operations:
|
||||||
|
// - GetOption: Try to extract a value of type A from S (may fail)
|
||||||
|
// - ReverseGet: Construct an S from an A (always succeeds)
|
||||||
|
//
|
||||||
|
// Prisms are useful for working with variant types like Either, Option,
|
||||||
|
// or custom sum types where you want to focus on a specific variant.
|
||||||
|
//
|
||||||
|
// Type Parameters:
|
||||||
|
// - S: The source type (sum type)
|
||||||
|
// - A: The focus type (variant within the sum type)
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
// type Result interface{ isResult() }
|
||||||
|
// type Success struct{ Value int }
|
||||||
|
// type Failure struct{ Error string }
|
||||||
|
//
|
||||||
|
// successPrism := MakePrism(
|
||||||
|
// func(r Result) Option[int] {
|
||||||
|
// if s, ok := r.(Success); ok {
|
||||||
|
// return Some(s.Value)
|
||||||
|
// }
|
||||||
|
// return None[int]()
|
||||||
|
// },
|
||||||
|
// func(v int) Result { return Success{Value: v} },
|
||||||
|
// )
|
||||||
Prism[S, A any] interface {
|
Prism[S, A any] interface {
|
||||||
GetOption(s S) O.Option[A]
|
// GetOption attempts to extract a value of type A from S.
|
||||||
|
// Returns Some(a) if the extraction succeeds, None otherwise.
|
||||||
|
GetOption(s S) Option[A]
|
||||||
|
|
||||||
|
// ReverseGet constructs an S from an A.
|
||||||
|
// This operation always succeeds.
|
||||||
ReverseGet(a A) S
|
ReverseGet(a A) S
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// prismImpl is the internal implementation of the Prism interface.
|
||||||
prismImpl[S, A any] struct {
|
prismImpl[S, A any] struct {
|
||||||
get func(S) O.Option[A]
|
get func(S) Option[A]
|
||||||
rev func(A) S
|
rev func(A) S
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
func (prism prismImpl[S, A]) GetOption(s S) O.Option[A] {
|
// GetOption implements the Prism interface for prismImpl.
|
||||||
|
func (prism prismImpl[S, A]) GetOption(s S) Option[A] {
|
||||||
return prism.get(s)
|
return prism.get(s)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ReverseGet implements the Prism interface for prismImpl.
|
||||||
func (prism prismImpl[S, A]) ReverseGet(a A) S {
|
func (prism prismImpl[S, A]) ReverseGet(a A) S {
|
||||||
return prism.rev(a)
|
return prism.rev(a)
|
||||||
}
|
}
|
||||||
|
|
||||||
func MakePrism[S, A any](get func(S) O.Option[A], rev func(A) S) Prism[S, A] {
|
// MakePrism constructs a Prism from GetOption and ReverseGet functions.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - get: Function to extract A from S (returns Option[A])
|
||||||
|
// - rev: Function to construct S from A
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - A Prism[S, A] that uses the provided functions
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// prism := MakePrism(
|
||||||
|
// func(opt Option[int]) Option[int] { return opt },
|
||||||
|
// func(n int) Option[int] { return Some(n) },
|
||||||
|
// )
|
||||||
|
func MakePrism[S, A any](get func(S) Option[A], rev func(A) S) Prism[S, A] {
|
||||||
return prismImpl[S, A]{get, rev}
|
return prismImpl[S, A]{get, rev}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Id returns a prism implementing the identity operation
|
// Id returns an identity prism that focuses on the entire value.
|
||||||
|
// GetOption always returns Some(s), and ReverseGet is the identity function.
|
||||||
|
//
|
||||||
|
// This is useful as a starting point for prism composition or when you need
|
||||||
|
// a prism that doesn't actually transform the value.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// idPrism := Id[int]()
|
||||||
|
// value := idPrism.GetOption(42) // Some(42)
|
||||||
|
// result := idPrism.ReverseGet(42) // 42
|
||||||
func Id[S any]() Prism[S, S] {
|
func Id[S any]() Prism[S, S] {
|
||||||
return MakePrism(O.Some[S], F.Identity[S])
|
return MakePrism(O.Some[S], F.Identity[S])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FromPredicate creates a prism that matches values satisfying a predicate.
|
||||||
|
// GetOption returns Some(s) if the predicate is true, None otherwise.
|
||||||
|
// ReverseGet is the identity function (doesn't validate the predicate).
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - pred: Predicate function to test values
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - A Prism[S, S] that filters based on the predicate
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// positivePrism := FromPredicate(func(n int) bool { return n > 0 })
|
||||||
|
// value := positivePrism.GetOption(42) // Some(42)
|
||||||
|
// value = positivePrism.GetOption(-5) // None[int]
|
||||||
func FromPredicate[S any](pred func(S) bool) Prism[S, S] {
|
func FromPredicate[S any](pred func(S) bool) Prism[S, S] {
|
||||||
return MakePrism(O.FromPredicate(pred), F.Identity[S])
|
return MakePrism(O.FromPredicate(pred), F.Identity[S])
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compose composes a `Prism` with a `Prism`.
|
// Compose composes two prisms to create a prism that focuses deeper into a structure.
|
||||||
|
// The resulting prism first applies the outer prism (S → A), then the inner prism (A → B).
|
||||||
|
//
|
||||||
|
// Type Parameters:
|
||||||
|
// - S: The outermost source type
|
||||||
|
// - A: The intermediate type
|
||||||
|
// - B: The innermost focus type
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - ab: The inner prism (A → B)
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - A function that takes the outer prism (S → A) and returns the composed prism (S → B)
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// outerPrism := MakePrism(...) // Prism[Outer, Inner]
|
||||||
|
// innerPrism := MakePrism(...) // Prism[Inner, Value]
|
||||||
|
// composed := Compose[Outer](innerPrism)(outerPrism) // Prism[Outer, Value]
|
||||||
func Compose[S, A, B any](ab Prism[A, B]) func(Prism[S, A]) Prism[S, B] {
|
func Compose[S, A, B any](ab Prism[A, B]) func(Prism[S, A]) Prism[S, B] {
|
||||||
return func(sa Prism[S, A]) Prism[S, B] {
|
return func(sa Prism[S, A]) Prism[S, B] {
|
||||||
return MakePrism(F.Flow2(
|
return MakePrism(F.Flow2(
|
||||||
@@ -69,7 +159,10 @@ func Compose[S, A, B any](ab Prism[A, B]) func(Prism[S, A]) Prism[S, B] {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func prismModifyOption[S, A any](f func(A) A, sa Prism[S, A], s S) O.Option[S] {
|
// prismModifyOption applies a transformation function through a prism,
|
||||||
|
// returning Some(modified S) if the prism matches, None otherwise.
|
||||||
|
// This is an internal helper function.
|
||||||
|
func prismModifyOption[S, A any](f func(A) A, sa Prism[S, A], s S) Option[S] {
|
||||||
return F.Pipe2(
|
return F.Pipe2(
|
||||||
s,
|
s,
|
||||||
sa.GetOption,
|
sa.GetOption,
|
||||||
@@ -80,6 +173,10 @@ func prismModifyOption[S, A any](f func(A) A, sa Prism[S, A], s S) O.Option[S] {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// prismModify applies a transformation function through a prism.
|
||||||
|
// If the prism matches, it extracts the value, applies the function,
|
||||||
|
// and reconstructs the result. If the prism doesn't match, returns the original value.
|
||||||
|
// This is an internal helper function.
|
||||||
func prismModify[S, A any](f func(A) A, sa Prism[S, A], s S) S {
|
func prismModify[S, A any](f func(A) A, sa Prism[S, A], s S) S {
|
||||||
return F.Pipe1(
|
return F.Pipe1(
|
||||||
prismModifyOption(f, sa, s),
|
prismModifyOption(f, sa, s),
|
||||||
@@ -87,23 +184,63 @@ func prismModify[S, A any](f func(A) A, sa Prism[S, A], s S) S {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// prismSet is an internal helper that creates a setter function.
|
||||||
|
// Deprecated: Use Set instead.
|
||||||
func prismSet[S, A any](a A) func(Prism[S, A]) EM.Endomorphism[S] {
|
func prismSet[S, A any](a A) func(Prism[S, A]) EM.Endomorphism[S] {
|
||||||
return EM.Curry3(prismModify[S, A])(F.Constant1[A](a))
|
return F.Curry3(prismModify[S, A])(F.Constant1[A](a))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set creates a function that sets a value through a prism.
|
||||||
|
// If the prism matches, it replaces the focused value with the new value.
|
||||||
|
// If the prism doesn't match, it returns the original value unchanged.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - a: The new value to set
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - A function that takes a prism and returns an endomorphism (S → S)
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// somePrism := MakePrism(...)
|
||||||
|
// setter := Set[Option[int], int](100)
|
||||||
|
// result := setter(somePrism)(Some(42)) // Some(100)
|
||||||
|
// result = setter(somePrism)(None[int]()) // None[int]() (unchanged)
|
||||||
func Set[S, A any](a A) func(Prism[S, A]) EM.Endomorphism[S] {
|
func Set[S, A any](a A) func(Prism[S, A]) EM.Endomorphism[S] {
|
||||||
return EM.Curry3(prismModify[S, A])(F.Constant1[A](a))
|
return F.Curry3(prismModify[S, A])(F.Constant1[A](a))
|
||||||
}
|
}
|
||||||
|
|
||||||
func prismSome[A any]() Prism[O.Option[A], A] {
|
// prismSome creates a prism that focuses on the Some variant of an Option.
|
||||||
return MakePrism(F.Identity[O.Option[A]], O.Some[A])
|
// This is an internal helper used by the Some function.
|
||||||
|
func prismSome[A any]() Prism[Option[A], A] {
|
||||||
|
return MakePrism(F.Identity[Option[A]], O.Some[A])
|
||||||
}
|
}
|
||||||
|
|
||||||
// Some returns a `Prism` from a `Prism` focused on the `Some` of a `Option` type.
|
// Some creates a prism that focuses on the Some variant of an Option within a structure.
|
||||||
func Some[S, A any](soa Prism[S, O.Option[A]]) Prism[S, A] {
|
// It composes the provided prism (which focuses on an Option[A]) with a prism that
|
||||||
|
// extracts the value from Some.
|
||||||
|
//
|
||||||
|
// Type Parameters:
|
||||||
|
// - S: The source type
|
||||||
|
// - A: The value type within the Option
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - soa: A prism that focuses on an Option[A] within S
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - A prism that focuses on the A value within Some
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// type Config struct { Timeout Option[int] }
|
||||||
|
// configPrism := MakePrism(...) // Prism[Config, Option[int]]
|
||||||
|
// timeoutPrism := Some(configPrism) // Prism[Config, int]
|
||||||
|
// value := timeoutPrism.GetOption(Config{Timeout: Some(30)}) // Some(30)
|
||||||
|
func Some[S, A any](soa Prism[S, Option[A]]) Prism[S, A] {
|
||||||
return Compose[S](prismSome[A]())(soa)
|
return Compose[S](prismSome[A]())(soa)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// imap is an internal helper that bidirectionally maps a prism's focus type.
|
||||||
func imap[S any, AB ~func(A) B, BA ~func(B) A, A, B any](sa Prism[S, A], ab AB, ba BA) Prism[S, B] {
|
func imap[S any, AB ~func(A) B, BA ~func(B) A, A, B any](sa Prism[S, A], ab AB, ba BA) Prism[S, B] {
|
||||||
return MakePrism(
|
return MakePrism(
|
||||||
F.Flow2(sa.GetOption, O.Map(ab)),
|
F.Flow2(sa.GetOption, O.Map(ab)),
|
||||||
@@ -111,6 +248,31 @@ func imap[S any, AB ~func(A) B, BA ~func(B) A, A, B any](sa Prism[S, A], ab AB,
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IMap bidirectionally maps the focus type of a prism.
|
||||||
|
// It transforms a Prism[S, A] into a Prism[S, B] using two functions:
|
||||||
|
// one to map A → B and another to map B → A.
|
||||||
|
//
|
||||||
|
// Type Parameters:
|
||||||
|
// - S: The source type
|
||||||
|
// - A: The original focus type
|
||||||
|
// - B: The new focus type
|
||||||
|
// - AB: Function type A → B
|
||||||
|
// - BA: Function type B → A
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - ab: Function to map from A to B
|
||||||
|
// - ba: Function to map from B to A
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - A function that transforms Prism[S, A] to Prism[S, B]
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// intPrism := MakePrism(...) // Prism[Result, int]
|
||||||
|
// stringPrism := IMap[Result](
|
||||||
|
// func(n int) string { return strconv.Itoa(n) },
|
||||||
|
// func(s string) int { n, _ := strconv.Atoi(s); return n },
|
||||||
|
// )(intPrism) // Prism[Result, string]
|
||||||
func IMap[S any, AB ~func(A) B, BA ~func(B) A, A, B any](ab AB, ba BA) func(Prism[S, A]) Prism[S, B] {
|
func IMap[S any, AB ~func(A) B, BA ~func(B) A, A, B any](ab AB, ba BA) func(Prism[S, A]) Prism[S, B] {
|
||||||
return func(sa Prism[S, A]) Prism[S, B] {
|
return func(sa Prism[S, A]) Prism[S, B] {
|
||||||
return imap(sa, ab, ba)
|
return imap(sa, ab, ba)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
312
v2/optics/prism/prisms.go
Normal file
312
v2/optics/prism/prisms.go
Normal file
@@ -0,0 +1,312 @@
|
|||||||
|
// Copyright (c) 2023 - 2025 IBM Corp.
|
||||||
|
// All rights reserved.
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
package prism
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"net/url"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/IBM/fp-go/v2/either"
|
||||||
|
F "github.com/IBM/fp-go/v2/function"
|
||||||
|
"github.com/IBM/fp-go/v2/option"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FromEncoding creates a prism for base64 encoding/decoding operations.
|
||||||
|
// It provides a safe way to work with base64-encoded strings, handling
|
||||||
|
// encoding and decoding errors gracefully through the Option type.
|
||||||
|
//
|
||||||
|
// The prism's GetOption attempts to decode a base64 string into bytes.
|
||||||
|
// If decoding succeeds, it returns Some([]byte); if it fails (e.g., invalid
|
||||||
|
// base64 format), it returns None.
|
||||||
|
//
|
||||||
|
// The prism's ReverseGet always succeeds, encoding bytes into a base64 string.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - enc: A base64.Encoding instance (e.g., base64.StdEncoding, base64.URLEncoding)
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - A Prism[string, []byte] that safely handles base64 encoding/decoding
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// // Create a prism for standard base64 encoding
|
||||||
|
// b64Prism := FromEncoding(base64.StdEncoding)
|
||||||
|
//
|
||||||
|
// // Decode valid base64 string
|
||||||
|
// data := b64Prism.GetOption("SGVsbG8gV29ybGQ=") // Some([]byte("Hello World"))
|
||||||
|
//
|
||||||
|
// // Decode invalid base64 string
|
||||||
|
// invalid := b64Prism.GetOption("not-valid-base64!!!") // None[[]byte]()
|
||||||
|
//
|
||||||
|
// // Encode bytes to base64
|
||||||
|
// encoded := b64Prism.ReverseGet([]byte("Hello World")) // "SGVsbG8gV29ybGQ="
|
||||||
|
//
|
||||||
|
// // Use with Set to update encoded values
|
||||||
|
// newData := []byte("New Data")
|
||||||
|
// setter := Set[string, []byte](newData)
|
||||||
|
// result := setter(b64Prism)("SGVsbG8gV29ybGQ=") // Encodes newData to base64
|
||||||
|
//
|
||||||
|
// Common use cases:
|
||||||
|
// - Safely decoding base64-encoded configuration values
|
||||||
|
// - Working with base64-encoded API responses
|
||||||
|
// - Validating and transforming base64 data in pipelines
|
||||||
|
// - Using different encodings (Standard, URL-safe, RawStd, RawURL)
|
||||||
|
func FromEncoding(enc *base64.Encoding) Prism[string, []byte] {
|
||||||
|
return MakePrism(F.Flow2(
|
||||||
|
either.Eitherize1(enc.DecodeString),
|
||||||
|
either.Fold(F.Ignore1of1[error](option.None[[]byte]), option.Some),
|
||||||
|
), enc.EncodeToString)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseURL creates a prism for parsing and formatting URLs.
|
||||||
|
// It provides a safe way to work with URL strings, handling parsing
|
||||||
|
// errors gracefully through the Option type.
|
||||||
|
//
|
||||||
|
// The prism's GetOption attempts to parse a string into a *url.URL.
|
||||||
|
// If parsing succeeds, it returns Some(*url.URL); if it fails (e.g., invalid
|
||||||
|
// URL format), it returns None.
|
||||||
|
//
|
||||||
|
// The prism's ReverseGet always succeeds, converting a *url.URL back to its
|
||||||
|
// string representation.
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - A Prism[string, *url.URL] that safely handles URL parsing/formatting
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// // Create a URL parsing prism
|
||||||
|
// urlPrism := ParseURL()
|
||||||
|
//
|
||||||
|
// // Parse valid URL
|
||||||
|
// parsed := urlPrism.GetOption("https://example.com/path?query=value")
|
||||||
|
// // Some(*url.URL{Scheme: "https", Host: "example.com", ...})
|
||||||
|
//
|
||||||
|
// // Parse invalid URL
|
||||||
|
// invalid := urlPrism.GetOption("ht!tp://invalid url") // None[*url.URL]()
|
||||||
|
//
|
||||||
|
// // Convert URL back to string
|
||||||
|
// u, _ := url.Parse("https://example.com")
|
||||||
|
// str := urlPrism.ReverseGet(u) // "https://example.com"
|
||||||
|
//
|
||||||
|
// // Use with Set to update URLs
|
||||||
|
// newURL, _ := url.Parse("https://newsite.com")
|
||||||
|
// setter := Set[string, *url.URL](newURL)
|
||||||
|
// result := setter(urlPrism)("https://oldsite.com") // "https://newsite.com"
|
||||||
|
//
|
||||||
|
// Common use cases:
|
||||||
|
// - Validating and parsing URL configuration values
|
||||||
|
// - Working with API endpoints
|
||||||
|
// - Transforming URL strings in data pipelines
|
||||||
|
// - Extracting and modifying URL components safely
|
||||||
|
func ParseURL() Prism[string, *url.URL] {
|
||||||
|
return MakePrism(F.Flow2(
|
||||||
|
either.Eitherize1(url.Parse),
|
||||||
|
either.Fold(F.Ignore1of1[error](option.None[*url.URL]), option.Some),
|
||||||
|
), (*url.URL).String)
|
||||||
|
}
|
||||||
|
|
||||||
|
// InstanceOf creates a prism for type assertions on interface{}/any values.
|
||||||
|
// It provides a safe way to extract values of a specific type from an any value,
|
||||||
|
// handling type mismatches gracefully through the Option type.
|
||||||
|
//
|
||||||
|
// The prism's GetOption attempts to assert that an any value is of type T.
|
||||||
|
// If the assertion succeeds, it returns Some(T); if it fails, it returns None.
|
||||||
|
//
|
||||||
|
// The prism's ReverseGet always succeeds, converting a value of type T back to any.
|
||||||
|
//
|
||||||
|
// Type Parameters:
|
||||||
|
// - T: The target type to extract from any
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - A Prism[any, T] that safely handles type assertions
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// // Create a prism for extracting int values
|
||||||
|
// intPrism := InstanceOf[int]()
|
||||||
|
//
|
||||||
|
// // Extract int from any
|
||||||
|
// var value any = 42
|
||||||
|
// result := intPrism.GetOption(value) // Some(42)
|
||||||
|
//
|
||||||
|
// // Type mismatch returns None
|
||||||
|
// var strValue any = "hello"
|
||||||
|
// result = intPrism.GetOption(strValue) // None[int]()
|
||||||
|
//
|
||||||
|
// // Convert back to any
|
||||||
|
// anyValue := intPrism.ReverseGet(42) // any(42)
|
||||||
|
//
|
||||||
|
// // Use with Set to update typed values
|
||||||
|
// setter := Set[any, int](100)
|
||||||
|
// result := setter(intPrism)(any(42)) // any(100)
|
||||||
|
//
|
||||||
|
// Common use cases:
|
||||||
|
// - Safely extracting typed values from interface{} collections
|
||||||
|
// - Working with heterogeneous data structures
|
||||||
|
// - Type-safe deserialization and validation
|
||||||
|
// - Pattern matching on interface{} values
|
||||||
|
func InstanceOf[T any]() Prism[any, T] {
|
||||||
|
return MakePrism(option.ToType[T], F.ToAny[T])
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseDate creates a prism for parsing and formatting dates with a specific layout.
|
||||||
|
// It provides a safe way to work with date strings, handling parsing errors
|
||||||
|
// gracefully through the Option type.
|
||||||
|
//
|
||||||
|
// The prism's GetOption attempts to parse a string into a time.Time using the
|
||||||
|
// specified layout. If parsing succeeds, it returns Some(time.Time); if it fails
|
||||||
|
// (e.g., invalid date format), it returns None.
|
||||||
|
//
|
||||||
|
// The prism's ReverseGet always succeeds, formatting a time.Time back to a string
|
||||||
|
// using the same layout.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - layout: The time layout string (e.g., "2006-01-02", time.RFC3339)
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - A Prism[string, time.Time] that safely handles date parsing/formatting
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// // Create a prism for ISO date format
|
||||||
|
// datePrism := ParseDate("2006-01-02")
|
||||||
|
//
|
||||||
|
// // Parse valid date
|
||||||
|
// parsed := datePrism.GetOption("2024-03-15")
|
||||||
|
// // Some(time.Time{2024, 3, 15, ...})
|
||||||
|
//
|
||||||
|
// // Parse invalid date
|
||||||
|
// invalid := datePrism.GetOption("not-a-date") // None[time.Time]()
|
||||||
|
//
|
||||||
|
// // Format date back to string
|
||||||
|
// date := time.Date(2024, 3, 15, 0, 0, 0, 0, time.UTC)
|
||||||
|
// str := datePrism.ReverseGet(date) // "2024-03-15"
|
||||||
|
//
|
||||||
|
// // Use with Set to update dates
|
||||||
|
// newDate := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||||
|
// setter := Set[string, time.Time](newDate)
|
||||||
|
// result := setter(datePrism)("2024-03-15") // "2025-01-01"
|
||||||
|
//
|
||||||
|
// // Different layouts for different formats
|
||||||
|
// rfc3339Prism := ParseDate(time.RFC3339)
|
||||||
|
// parsed = rfc3339Prism.GetOption("2024-03-15T10:30:00Z")
|
||||||
|
//
|
||||||
|
// Common use cases:
|
||||||
|
// - Validating and parsing date configuration values
|
||||||
|
// - Working with date strings in APIs
|
||||||
|
// - Converting between date formats
|
||||||
|
// - Safely handling user-provided date inputs
|
||||||
|
func ParseDate(layout string) Prism[string, time.Time] {
|
||||||
|
return MakePrism(F.Flow2(
|
||||||
|
F.Bind1st(either.Eitherize2(time.Parse), layout),
|
||||||
|
either.Fold(F.Ignore1of1[error](option.None[time.Time]), option.Some),
|
||||||
|
), F.Bind2nd(time.Time.Format, layout))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deref creates a prism for safely dereferencing pointers.
|
||||||
|
// It provides a safe way to work with nullable pointers, handling nil values
|
||||||
|
// gracefully through the Option type.
|
||||||
|
//
|
||||||
|
// The prism's GetOption attempts to dereference a pointer.
|
||||||
|
// If the pointer is non-nil, it returns Some(*T); if it's nil, it returns None.
|
||||||
|
//
|
||||||
|
// The prism's ReverseGet is the identity function, returning the pointer unchanged.
|
||||||
|
//
|
||||||
|
// Type Parameters:
|
||||||
|
// - T: The type being pointed to
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - A Prism[*T, *T] that safely handles pointer dereferencing
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// // Create a prism for dereferencing int pointers
|
||||||
|
// derefPrism := Deref[int]()
|
||||||
|
//
|
||||||
|
// // Dereference non-nil pointer
|
||||||
|
// value := 42
|
||||||
|
// ptr := &value
|
||||||
|
// result := derefPrism.GetOption(ptr) // Some(&42)
|
||||||
|
//
|
||||||
|
// // Dereference nil pointer
|
||||||
|
// var nilPtr *int
|
||||||
|
// result = derefPrism.GetOption(nilPtr) // None[*int]()
|
||||||
|
//
|
||||||
|
// // ReverseGet returns the pointer unchanged
|
||||||
|
// reconstructed := derefPrism.ReverseGet(ptr) // &42
|
||||||
|
//
|
||||||
|
// // Use with Set to update non-nil pointers
|
||||||
|
// newValue := 100
|
||||||
|
// newPtr := &newValue
|
||||||
|
// setter := Set[*int, *int](newPtr)
|
||||||
|
// result := setter(derefPrism)(ptr) // &100
|
||||||
|
// result = setter(derefPrism)(nilPtr) // nil (unchanged)
|
||||||
|
//
|
||||||
|
// Common use cases:
|
||||||
|
// - Safely working with optional pointer fields
|
||||||
|
// - Validating non-nil pointers before operations
|
||||||
|
// - Filtering out nil values in data pipelines
|
||||||
|
// - Working with database nullable columns
|
||||||
|
func Deref[T any]() Prism[*T, *T] {
|
||||||
|
return MakePrism(option.FromNillable[T], F.Identity[*T])
|
||||||
|
}
|
||||||
|
|
||||||
|
// FromEither creates a prism for extracting Right values from Either types.
|
||||||
|
// It provides a safe way to work with Either values, focusing on the success case
|
||||||
|
// and handling the error case gracefully through the Option type.
|
||||||
|
//
|
||||||
|
// The prism's GetOption attempts to extract the Right value from an Either.
|
||||||
|
// If the Either is Right(value), it returns Some(value); if it's Left(error), it returns None.
|
||||||
|
//
|
||||||
|
// The prism's ReverseGet always succeeds, wrapping a value into a Right.
|
||||||
|
//
|
||||||
|
// Type Parameters:
|
||||||
|
// - E: The error/left type
|
||||||
|
// - T: The value/right type
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - A Prism[Either[E, T], T] that safely extracts Right values
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// // Create a prism for extracting successful results
|
||||||
|
// resultPrism := FromEither[error, int]()
|
||||||
|
//
|
||||||
|
// // Extract from Right
|
||||||
|
// success := either.Right[error](42)
|
||||||
|
// result := resultPrism.GetOption(success) // Some(42)
|
||||||
|
//
|
||||||
|
// // Extract from Left
|
||||||
|
// failure := either.Left[int](errors.New("failed"))
|
||||||
|
// result = resultPrism.GetOption(failure) // None[int]()
|
||||||
|
//
|
||||||
|
// // Wrap value into Right
|
||||||
|
// wrapped := resultPrism.ReverseGet(100) // Right(100)
|
||||||
|
//
|
||||||
|
// // Use with Set to update successful results
|
||||||
|
// setter := Set[Either[error, int], int](200)
|
||||||
|
// result := setter(resultPrism)(success) // Right(200)
|
||||||
|
// result = setter(resultPrism)(failure) // Left(error) (unchanged)
|
||||||
|
//
|
||||||
|
// Common use cases:
|
||||||
|
// - Extracting successful values from Either results
|
||||||
|
// - Filtering out errors in data pipelines
|
||||||
|
// - Working with fallible operations
|
||||||
|
// - Composing with other prisms for complex error handling
|
||||||
|
func FromEither[E, T any]() Prism[Either[E, T], T] {
|
||||||
|
return MakePrism(either.ToOption[E, T], either.Of[E, T])
|
||||||
|
}
|
||||||
@@ -20,7 +20,43 @@ import (
|
|||||||
O "github.com/IBM/fp-go/v2/option"
|
O "github.com/IBM/fp-go/v2/option"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AsTraversal converts a prism to a traversal
|
// AsTraversal converts a Prism into a Traversal.
|
||||||
|
//
|
||||||
|
// A Traversal is a more general optic that can focus on zero or more values,
|
||||||
|
// while a Prism focuses on zero or one value. This function lifts a Prism
|
||||||
|
// into the Traversal abstraction, allowing it to be used in contexts that
|
||||||
|
// expect traversals.
|
||||||
|
//
|
||||||
|
// The conversion works by:
|
||||||
|
// - If the prism matches (GetOption returns Some), the traversal focuses on that value
|
||||||
|
// - If the prism doesn't match (GetOption returns None), the traversal focuses on zero values
|
||||||
|
//
|
||||||
|
// Type Parameters:
|
||||||
|
// - R: The traversal function type ~func(func(A) HKTA) func(S) HKTS
|
||||||
|
// - S: The source type
|
||||||
|
// - A: The focus type
|
||||||
|
// - HKTS: Higher-kinded type for S (e.g., functor/applicative context)
|
||||||
|
// - HKTA: Higher-kinded type for A (e.g., functor/applicative context)
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - fof: Function to lift S into the higher-kinded type HKTS (pure/of operation)
|
||||||
|
// - fmap: Function to map over HKTA and produce HKTS (functor map operation)
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - A function that converts a Prism[S, A] into a Traversal R
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// // Convert a prism to a traversal for use with applicative functors
|
||||||
|
// prism := MakePrism(...)
|
||||||
|
// traversal := AsTraversal(
|
||||||
|
// func(s S) HKTS { return pure(s) },
|
||||||
|
// func(hkta HKTA, f func(A) S) HKTS { return fmap(hkta, f) },
|
||||||
|
// )(prism)
|
||||||
|
//
|
||||||
|
// Note: This function is typically used in advanced scenarios involving
|
||||||
|
// higher-kinded types and applicative functors. Most users will work
|
||||||
|
// directly with prisms rather than converting them to traversals.
|
||||||
func AsTraversal[R ~func(func(A) HKTA) func(S) HKTS, S, A, HKTS, HKTA any](
|
func AsTraversal[R ~func(func(A) HKTA) func(S) HKTS, S, A, HKTS, HKTA any](
|
||||||
fof func(S) HKTS,
|
fof func(S) HKTS,
|
||||||
fmap func(HKTA, func(A) S) HKTS,
|
fmap func(HKTA, func(A) S) HKTS,
|
||||||
@@ -32,7 +68,9 @@ func AsTraversal[R ~func(func(A) HKTA) func(S) HKTS, S, A, HKTS, HKTA any](
|
|||||||
s,
|
s,
|
||||||
sa.GetOption,
|
sa.GetOption,
|
||||||
O.Fold(
|
O.Fold(
|
||||||
|
// If prism doesn't match, return the original value lifted into HKTS
|
||||||
F.Nullary2(F.Constant(s), fof),
|
F.Nullary2(F.Constant(s), fof),
|
||||||
|
// If prism matches, apply f to the extracted value and map back
|
||||||
func(a A) HKTS {
|
func(a A) HKTS {
|
||||||
return fmap(f(a), func(a A) S {
|
return fmap(f(a), func(a A) S {
|
||||||
return prismModify(F.Constant1[A](a), sa, s)
|
return prismModify(F.Constant1[A](a), sa, s)
|
||||||
|
|||||||
96
v2/optics/prism/types.go
Normal file
96
v2/optics/prism/types.go
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
// Copyright (c) 2023 - 2025 IBM Corp.
|
||||||
|
// All rights reserved.
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
package prism
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/IBM/fp-go/v2/either"
|
||||||
|
O "github.com/IBM/fp-go/v2/option"
|
||||||
|
)
|
||||||
|
|
||||||
|
type (
|
||||||
|
// Option is a type alias for O.Option[T], representing an optional value.
|
||||||
|
// It is re-exported here for convenience when working with prisms.
|
||||||
|
//
|
||||||
|
// An Option[T] can be either:
|
||||||
|
// - Some(value): Contains a value of type T
|
||||||
|
// - None: Represents the absence of a value
|
||||||
|
//
|
||||||
|
// This type is commonly used in prism operations, particularly in the
|
||||||
|
// GetOption method which returns Option[A] to indicate whether a value
|
||||||
|
// could be extracted from the source type.
|
||||||
|
//
|
||||||
|
// Type Parameters:
|
||||||
|
// - T: The type of the value that may or may not be present
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// // A prism's GetOption returns an Option
|
||||||
|
// prism := MakePrism(...)
|
||||||
|
// result := prism.GetOption(value) // Returns Option[A]
|
||||||
|
//
|
||||||
|
// // Check if the value was extracted successfully
|
||||||
|
// if O.IsSome(result) {
|
||||||
|
// // Value was found
|
||||||
|
// } else {
|
||||||
|
// // Value was not found (None)
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// See also:
|
||||||
|
// - github.com/IBM/fp-go/v2/option for the full Option API
|
||||||
|
// - Prism.GetOption for the primary use case within this package
|
||||||
|
Option[T any] = O.Option[T]
|
||||||
|
|
||||||
|
// Either is a type alias for either.Either[E, T], representing a value that can be one of two types.
|
||||||
|
// It is re-exported here for convenience when working with prisms that handle error cases.
|
||||||
|
//
|
||||||
|
// An Either[E, T] can be either:
|
||||||
|
// - Left(error): Contains an error value of type E
|
||||||
|
// - Right(value): Contains a success value of type T
|
||||||
|
//
|
||||||
|
// This type is commonly used in prism operations for error handling, particularly with
|
||||||
|
// the FromEither prism which extracts Right values and returns None for Left values.
|
||||||
|
//
|
||||||
|
// Type Parameters:
|
||||||
|
// - E: The type of the error/left value
|
||||||
|
// - T: The type of the success/right value
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// // Using FromEither prism to extract success values
|
||||||
|
// prism := FromEither[error, int]()
|
||||||
|
//
|
||||||
|
// // Extract from a Right value
|
||||||
|
// success := either.Right[error](42)
|
||||||
|
// result := prism.GetOption(success) // Returns Some(42)
|
||||||
|
//
|
||||||
|
// // Extract from a Left value
|
||||||
|
// failure := either.Left[int](errors.New("failed"))
|
||||||
|
// result = prism.GetOption(failure) // Returns None
|
||||||
|
//
|
||||||
|
// // ReverseGet wraps a value into Right
|
||||||
|
// wrapped := prism.ReverseGet(100) // Returns Right(100)
|
||||||
|
//
|
||||||
|
// Common Use Cases:
|
||||||
|
// - Error handling in functional pipelines
|
||||||
|
// - Representing computations that may fail
|
||||||
|
// - Composing prisms that work with Either types
|
||||||
|
//
|
||||||
|
// See also:
|
||||||
|
// - github.com/IBM/fp-go/v2/either for the full Either API
|
||||||
|
// - FromEither for creating prisms that work with Either types
|
||||||
|
// - Prism composition for building complex error-handling pipelines
|
||||||
|
Either[E, T any] = either.Either[E, T]
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user