From 92eb9715bdfb910796ea4161ce350ca80476cc27 Mon Sep 17 00:00:00 2001 From: "Dr. Carsten Leue" Date: Thu, 6 Nov 2025 13:53:02 +0100 Subject: [PATCH] fix: implement some useful prisms Signed-off-by: Dr. Carsten Leue --- v2/array/slice_test.go | 2 - .../readerioeither/http/builder/builder.go | 83 ++ .../http/builder/builder_test.go | 228 +++ .../readerioeither/http/builder/coverage.out | 15 + v2/context/readerioeither/http/coverage.out | 11 + v2/context/readerioeither/http/request.go | 179 ++- .../readerioeither/http/request_test.go | 164 ++- v2/http/builder/builder.go | 56 + v2/http/builder/builder_test.go | 351 +++++ v2/http/builder/coverage.out | 36 + v2/http/headers/headers.go | 120 +- v2/http/headers/headers_test.go | 279 ++++ v2/http/types.go | 78 +- v2/http/utils.go | 185 ++- v2/http/utils_test.go | 344 ++++- v2/logging/coverage.out | 5 + v2/logging/logger.go | 32 + v2/logging/logger_test.go | 288 ++++ v2/optics/iso/iso.go | 323 +++- v2/optics/prism/prism.go | 192 ++- v2/optics/prism/prism_test.go | 1297 ++++++++++++++++- v2/optics/prism/prisms.go | 312 ++++ v2/optics/prism/traversal.go | 40 +- v2/optics/prism/types.go | 96 ++ 24 files changed, 4636 insertions(+), 80 deletions(-) create mode 100644 v2/context/readerioeither/http/builder/coverage.out create mode 100644 v2/context/readerioeither/http/coverage.out create mode 100644 v2/http/builder/coverage.out create mode 100644 v2/logging/coverage.out create mode 100644 v2/logging/logger_test.go create mode 100644 v2/optics/prism/prisms.go create mode 100644 v2/optics/prism/types.go diff --git a/v2/array/slice_test.go b/v2/array/slice_test.go index 22066fb..442f3dd 100644 --- a/v2/array/slice_test.go +++ b/v2/array/slice_test.go @@ -403,5 +403,3 @@ func TestSlicePropertyBased(t *testing.T) { } }) } - -// Made with Bob diff --git a/v2/context/readerioeither/http/builder/builder.go b/v2/context/readerioeither/http/builder/builder.go index cbd3ec8..c75c063 100644 --- a/v2/context/readerioeither/http/builder/builder.go +++ b/v2/context/readerioeither/http/builder/builder.go @@ -13,6 +13,36 @@ // See the License for the specific language governing permissions and // 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 import ( @@ -31,6 +61,59 @@ import ( 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 { withBody := F.Curry3(func(data []byte, url string, method string) RIOE.ReaderIOEither[*http.Request] { diff --git a/v2/context/readerioeither/http/builder/builder_test.go b/v2/context/readerioeither/http/builder/builder_test.go index 311e719..4b8241b 100644 --- a/v2/context/readerioeither/http/builder/builder_test.go +++ b/v2/context/readerioeither/http/builder/builder_test.go @@ -57,3 +57,231 @@ func TestBuilderWithQuery(t *testing.T) { 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")) +} diff --git a/v2/context/readerioeither/http/builder/coverage.out b/v2/context/readerioeither/http/builder/coverage.out new file mode 100644 index 0000000..3d61c5a --- /dev/null +++ b/v2/context/readerioeither/http/builder/coverage.out @@ -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 diff --git a/v2/context/readerioeither/http/coverage.out b/v2/context/readerioeither/http/coverage.out new file mode 100644 index 0000000..4e50aac --- /dev/null +++ b/v2/context/readerioeither/http/coverage.out @@ -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 diff --git a/v2/context/readerioeither/http/request.go b/v2/context/readerioeither/http/request.go index eea691c..1756136 100644 --- a/v2/context/readerioeither/http/request.go +++ b/v2/context/readerioeither/http/request.go @@ -13,6 +13,22 @@ // See the License for the specific language governing permissions and // 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 import ( @@ -30,14 +46,31 @@ import ( ) 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] + // 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 { - // 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] } + // client is the internal implementation of the Client interface. + // It wraps a standard http.Client and provides functional HTTP operations. client struct { delegate *http.Client doIOE func(*http.Request) IOE.IOEither[error, *http.Response] @@ -45,11 +78,33 @@ type ( ) 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 is a partially applied version of MakeRequest with the context parameter bound. 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) ) @@ -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 { 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] { return func(req Requester) RIOE.ReaderIOEither[H.FullResponse] { 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] { return F.Flow2( 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] { return F.Flow2( 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] { 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] { return F.Flow3( 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] { return F.Flow2( readJSON(client), diff --git a/v2/context/readerioeither/http/request_test.go b/v2/context/readerioeither/http/request_test.go index 2250d24..19536d9 100644 --- a/v2/context/readerioeither/http/request_test.go +++ b/v2/context/readerioeither/http/request_test.go @@ -88,7 +88,7 @@ func TestSendSingleRequest(t *testing.T) { resp1 := readItem(req1) - resE := resp1(context.TODO())() + resE := resp1(t.Context())() fmt.Println(resE) } @@ -121,7 +121,7 @@ func TestSendSingleRequestWithHeaderUnsafe(t *testing.T) { ) res := F.Pipe1( - resp1(context.TODO())(), + resp1(t.Context())(), E.GetOrElse(errors.ToString), ) @@ -149,9 +149,167 @@ func TestSendSingleRequestWithHeaderSafe(t *testing.T) { ) res := F.Pipe1( - response(context.TODO())(), + response(t.Context())(), E.GetOrElse(errors.ToString), ) 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") +} diff --git a/v2/http/builder/builder.go b/v2/http/builder/builder.go index 6fc7ba6..47c3b9f 100644 --- a/v2/http/builder/builder.go +++ b/v2/http/builder/builder.go @@ -13,6 +13,62 @@ // See the License for the specific language governing permissions and // 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 import ( diff --git a/v2/http/builder/builder_test.go b/v2/http/builder/builder_test.go index c04639b..8ba157a 100644 --- a/v2/http/builder/builder_test.go +++ b/v2/http/builder/builder_test.go @@ -17,8 +17,11 @@ package builder import ( "fmt" + "net/http" + "net/url" "testing" + E "github.com/IBM/fp-go/v2/either" F "github.com/IBM/fp-go/v2/function" C "github.com/IBM/fp-go/v2/http/content" FD "github.com/IBM/fp-go/v2/http/form" @@ -91,3 +94,351 @@ func TestHash(t *testing.T) { 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())) +} diff --git a/v2/http/builder/coverage.out b/v2/http/builder/coverage.out new file mode 100644 index 0000000..40f71ad --- /dev/null +++ b/v2/http/builder/coverage.out @@ -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 diff --git a/v2/http/headers/headers.go b/v2/http/headers/headers.go index 5fc8d74..417ee1c 100644 --- a/v2/http/headers/headers.go +++ b/v2/http/headers/headers.go @@ -13,6 +13,56 @@ // See the License for the specific language governing permissions and // 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 import ( @@ -27,30 +77,88 @@ import ( 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 ( - 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" - 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" ) 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]()) - // 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( textproto.CanonicalMIMEHeaderKey, 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( LA.AtHead[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( AtValues, composeHead, diff --git a/v2/http/headers/headers_test.go b/v2/http/headers/headers_test.go index 1d4f0f0..a0e04f7 100644 --- a/v2/http/headers/headers_test.go +++ b/v2/http/headers/headers_test.go @@ -21,6 +21,7 @@ import ( A "github.com/IBM/fp-go/v2/array" "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" O "github.com/IBM/fp-go/v2/option" 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(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")) +} diff --git a/v2/http/types.go b/v2/http/types.go index 5d0d458..63206bf 100644 --- a/v2/http/types.go +++ b/v2/http/types.go @@ -13,6 +13,53 @@ // See the License for the specific language governing permissions and // 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 import ( @@ -22,11 +69,38 @@ import ( ) 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] ) 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] - 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] ) diff --git a/v2/http/utils.go b/v2/http/utils.go index c2b24c1..62c8348 100644 --- a/v2/http/utils.go +++ b/v2/http/utils.go @@ -33,8 +33,29 @@ import ( ) 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] + // 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 { statusCode int headers H.Header @@ -44,11 +65,28 @@ type ( ) 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 - // 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) - // 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( ParseMediaType, E.ChainFirst(F.Flow2( @@ -56,7 +94,21 @@ var ( 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( E.Of[error, *H.Response], E.ChainFirst(F.Flow5( @@ -66,60 +118,175 @@ var ( E.FromOption[string](errors.OnNone("unable to access the [%s] header", HeaderContentType)), 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 ) 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" ) -// 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] { m, p, err := mime.ParseMediaType(mediaType) 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { // read the body bodyRdr := GetBody(resp) diff --git a/v2/http/utils_test.go b/v2/http/utils_test.go index 89f27c5..8bb00a0 100644 --- a/v2/http/utils_test.go +++ b/v2/http/utils_test.go @@ -16,12 +16,18 @@ package http import ( + "bytes" + "io" + H "net/http" + "net/url" "testing" E "github.com/IBM/fp-go/v2/either" F "github.com/IBM/fp-go/v2/function" 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/require" ) 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) { - res := F.Pipe1( validateJSONContentTypeString(C.JSON), NoError[ParsedMediaType](t), ) - assert.True(t, res) } func TestValidateInvalidJsonContentTypeString(t *testing.T) { - res := F.Pipe1( validateJSONContentTypeString("application/xml"), Error[ParsedMediaType](t), ) - 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)) +} diff --git a/v2/logging/coverage.out b/v2/logging/coverage.out new file mode 100644 index 0000000..b142efc --- /dev/null +++ b/v2/logging/coverage.out @@ -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 diff --git a/v2/logging/logger.go b/v2/logging/logger.go index cc4c76c..313615f 100644 --- a/v2/logging/logger.go +++ b/v2/logging/logger.go @@ -13,12 +13,44 @@ // See the License for the specific language governing permissions and // 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 import ( "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)) { switch len(loggers) { case 0: diff --git a/v2/logging/logger_test.go b/v2/logging/logger_test.go new file mode 100644 index 0000000..34687b2 --- /dev/null +++ b/v2/logging/logger_test.go @@ -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) + } +} diff --git a/v2/optics/iso/iso.go b/v2/optics/iso/iso.go index 5a2b4e5..5b81cea 100644 --- a/v2/optics/iso/iso.go +++ b/v2/optics/iso/iso.go @@ -13,7 +13,7 @@ // See the License for the specific language governing permissions and // 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 import ( @@ -21,21 +21,127 @@ import ( 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 { - 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 } +// 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] { 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] { 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] { return func(sa Iso[S, A]) Iso[S, B] { 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] { return MakeIso( 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 { return F.Pipe3( 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] { - 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 { return func(sa Iso[S, A]) A { 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 { return func(sa Iso[S, A]) S { 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 { 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 { 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] { return MakeIso( 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] { return func(sa Iso[S, A]) Iso[S, B] { return imap(sa, ab, ba) diff --git a/v2/optics/prism/prism.go b/v2/optics/prism/prism.go index b6b78be..9344b8f 100644 --- a/v2/optics/prism/prism.go +++ b/v2/optics/prism/prism.go @@ -13,7 +13,6 @@ // See the License for the specific language governing permissions and // limitations under the License. -// Prism is an optic used to select part of a sum type. package prism import ( @@ -23,40 +22,131 @@ import ( ) 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 { - 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 } + // prismImpl is the internal implementation of the Prism interface. prismImpl[S, A any] struct { - get func(S) O.Option[A] + get func(S) Option[A] 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) } +// ReverseGet implements the Prism interface for prismImpl. func (prism prismImpl[S, A]) ReverseGet(a A) S { 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} } -// 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] { 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] { 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] { return func(sa Prism[S, A]) Prism[S, B] { 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( s, 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 { return F.Pipe1( 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] { - 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] { - 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] { - return MakePrism(F.Identity[O.Option[A]], O.Some[A]) +// prismSome creates a prism that focuses on the Some variant of an Option. +// 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. -func Some[S, A any](soa Prism[S, O.Option[A]]) Prism[S, A] { +// Some creates a prism that focuses on the Some variant of an Option within a structure. +// 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) } +// 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] { return MakePrism( 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] { return func(sa Prism[S, A]) Prism[S, B] { return imap(sa, ab, ba) diff --git a/v2/optics/prism/prism_test.go b/v2/optics/prism/prism_test.go index c5881ca..75428c3 100644 --- a/v2/optics/prism/prism_test.go +++ b/v2/optics/prism/prism_test.go @@ -16,15 +16,20 @@ package prism import ( + "encoding/base64" + "errors" + "net/url" "testing" + "time" + E "github.com/IBM/fp-go/v2/either" F "github.com/IBM/fp-go/v2/function" O "github.com/IBM/fp-go/v2/option" "github.com/stretchr/testify/assert" ) func TestSome(t *testing.T) { - somePrism := MakePrism(F.Identity[O.Option[int]], O.Some[int]) + somePrism := MakePrism(F.Identity[Option[int]], O.Some[int]) assert.Equal(t, O.Some(1), somePrism.GetOption(O.Some(1))) } @@ -61,7 +66,7 @@ func TestFromPredicate(t *testing.T) { func TestCompose(t *testing.T) { // Prism for Some values somePrism := MakePrism( - F.Identity[O.Option[int]], + F.Identity[Option[int]], O.Some[int], ) @@ -73,7 +78,7 @@ func TestCompose(t *testing.T) { // Compose: Option[int] -> int (if Some and positive) composedPrism := F.Pipe1( somePrism, - Compose[O.Option[int]](positivePrism), + Compose[Option[int]](positivePrism), ) // Test with Some positive @@ -92,30 +97,30 @@ func TestCompose(t *testing.T) { func TestSet(t *testing.T) { // Prism for Some values somePrism := MakePrism( - F.Identity[O.Option[int]], + F.Identity[Option[int]], O.Some[int], ) // Set value when it matches - result := Set[O.Option[int], int](100)(somePrism)(O.Some(42)) + result := Set[Option[int], int](100)(somePrism)(O.Some(42)) assert.Equal(t, O.Some(100), result) // No change when it doesn't match - result = Set[O.Option[int], int](100)(somePrism)(O.None[int]()) + result = Set[Option[int], int](100)(somePrism)(O.None[int]()) assert.Equal(t, O.None[int](), result) } func TestSomeFunction(t *testing.T) { // Prism that focuses on an Option field type Config struct { - Timeout O.Option[int] + Timeout Option[int] } configPrism := MakePrism( - func(c Config) O.Option[O.Option[int]] { + func(c Config) Option[Option[int]] { return O.Some(c.Timeout) }, - func(t O.Option[int]) Config { + func(t Option[int]) Config { return Config{Timeout: t} }, ) @@ -139,14 +144,14 @@ func TestSomeFunction(t *testing.T) { func TestIMap(t *testing.T) { // Prism for Some values somePrism := MakePrism( - F.Identity[O.Option[int]], + F.Identity[Option[int]], O.Some[int], ) // Map to string and back stringPrism := F.Pipe1( somePrism, - IMap[O.Option[int]]( + IMap[Option[int]]( func(n int) string { if n == 42 { return "42" @@ -178,7 +183,7 @@ func TestIMap(t *testing.T) { func TestPrismLaws(t *testing.T) { // Test prism laws with a simple prism somePrism := MakePrism( - F.Identity[O.Option[int]], + F.Identity[Option[int]], O.Some[int], ) @@ -201,12 +206,12 @@ func TestPrismLaws(t *testing.T) { func TestPrismModifyOption(t *testing.T) { // Test the internal prismModifyOption function through Set somePrism := MakePrism( - F.Identity[O.Option[int]], + F.Identity[Option[int]], O.Some[int], ) // Modify when match - setFn := Set[O.Option[int], int](100) + setFn := Set[Option[int], int](100) result := setFn(somePrism)(O.Some(42)) assert.Equal(t, O.Some(100), result) @@ -226,7 +231,7 @@ func (testFailure) isResult() {} func TestPrismWithCustomType(t *testing.T) { // Create prism for Success variant successPrism := MakePrism( - func(r testResult) O.Option[int] { + func(r testResult) Option[int] { if s, ok := r.(testSuccess); ok { return O.Some(s.Value) } @@ -258,3 +263,1265 @@ func TestPrismWithCustomType(t *testing.T) { unchanged := setFn(successPrism)(failure) assert.Equal(t, failure, unchanged) } + +// TestPrismModify tests the prismModify internal function through various scenarios +func TestPrismModify(t *testing.T) { + somePrism := MakePrism( + F.Identity[Option[int]], + O.Some[int], + ) + + // Test modify with matching value + result := Set[Option[int], int](84)(somePrism)(O.Some(42)) + assert.Equal(t, O.Some(84), result) + + // Test that original is returned when no match + result = Set[Option[int], int](100)(somePrism)(O.None[int]()) + assert.Equal(t, O.None[int](), result) +} + +// TestPrismModifyWithTransform tests modifying through a prism with a transformation +func TestPrismModifyWithTransform(t *testing.T) { + // Create a prism for positive numbers + positivePrism := FromPredicate(func(n int) bool { return n > 0 }) + + // Modify positive number + setter := Set[int, int](100) + result := setter(positivePrism)(42) + assert.Equal(t, 100, result) + + // Try to modify negative number (no change) + result = setter(positivePrism)(-5) + assert.Equal(t, -5, result) +} + +// TestAsTraversal tests converting a prism to a traversal +func TestAsTraversal(t *testing.T) { + somePrism := MakePrism( + F.Identity[Option[int]], + O.Some[int], + ) + + // Simple identity functor for testing + type Identity[A any] struct{ Value A } + + fof := func(s Option[int]) Identity[Option[int]] { + return Identity[Option[int]]{Value: s} + } + + fmap := func(ia Identity[int], f func(int) Option[int]) Identity[Option[int]] { + return Identity[Option[int]]{Value: f(ia.Value)} + } + + type TraversalFunc func(func(int) Identity[int]) func(Option[int]) Identity[Option[int]] + traversal := AsTraversal[TraversalFunc](fof, fmap)(somePrism) + + // Test with Some value + f := func(n int) Identity[int] { + return Identity[int]{Value: n * 2} + } + result := traversal(f)(O.Some(21)) + assert.Equal(t, O.Some(42), result.Value) + + // Test with None value + result = traversal(f)(O.None[int]()) + assert.Equal(t, O.None[int](), result.Value) +} + +// Test types for composition chain test +type testOuter struct{ Middle Option[testInner] } +type testInner struct{ Value Option[int] } + +// TestPrismCompositionChain tests composing multiple prisms +func TestPrismCompositionChain(t *testing.T) { + // Three-level composition + + outerPrism := MakePrism( + func(o testOuter) Option[Option[testInner]] { + return O.Some(o.Middle) + }, + func(m Option[testInner]) testOuter { + return testOuter{Middle: m} + }, + ) + + middlePrism := MakePrism( + F.Identity[Option[testInner]], + O.Some[testInner], + ) + + innerPrism := MakePrism( + func(i testInner) Option[Option[int]] { + return O.Some(i.Value) + }, + func(v Option[int]) testInner { + return testInner{Value: v} + }, + ) + + // Compose all three + composed := F.Pipe2( + outerPrism, + Compose[testOuter](middlePrism), + Compose[testOuter](innerPrism), + ) + + // Further compose to get the int value + finalPrism := Some(composed) + + // Test extraction through all layers + outer := testOuter{Middle: O.Some(testInner{Value: O.Some(42)})} + value := finalPrism.GetOption(outer) + assert.Equal(t, O.Some(42), value) + + // Test with None at middle layer + outerNone := testOuter{Middle: O.None[testInner]()} + value = finalPrism.GetOption(outerNone) + assert.Equal(t, O.None[int](), value) + + // Test with None at inner layer + outerInnerNone := testOuter{Middle: O.Some(testInner{Value: O.None[int]()})} + value = finalPrism.GetOption(outerInnerNone) + assert.Equal(t, O.None[int](), value) +} + +// TestPrismSetMultipleTimes tests setting values multiple times +func TestPrismSetMultipleTimes(t *testing.T) { + somePrism := MakePrism( + F.Identity[Option[int]], + O.Some[int], + ) + + // Chain multiple sets + result := F.Pipe3( + O.Some(10), + Set[Option[int], int](20)(somePrism), + Set[Option[int], int](30)(somePrism), + Set[Option[int], int](40)(somePrism), + ) + + assert.Equal(t, O.Some(40), result) +} + +// TestIMapBidirectional tests that IMap maintains bidirectionality +func TestIMapBidirectional(t *testing.T) { + somePrism := MakePrism( + F.Identity[Option[int]], + O.Some[int], + ) + + // Map int to string and back + stringPrism := F.Pipe1( + somePrism, + IMap[Option[int]]( + func(n int) string { + if n == 42 { + return "forty-two" + } + return "other" + }, + func(s string) int { + if s == "forty-two" { + return 42 + } + return 0 + }, + ), + ) + + // Test GetOption with mapping + result := stringPrism.GetOption(O.Some(42)) + assert.Equal(t, O.Some("forty-two"), result) + + // Test ReverseGet with reverse mapping + opt := stringPrism.ReverseGet("forty-two") + assert.Equal(t, O.Some(42), opt) + + // Verify round-trip + value := stringPrism.GetOption(stringPrism.ReverseGet("forty-two")) + assert.Equal(t, O.Some("forty-two"), value) +} + +// Test types for complex sum type test +type Shape interface{ isShape() } +type Circle struct{ Radius float64 } +type Rectangle struct{ Width, Height float64 } +type Triangle struct{ Base, Height float64 } + +func (Circle) isShape() {} +func (Rectangle) isShape() {} +func (Triangle) isShape() {} + +// TestPrismWithComplexSumType tests prism with a more complex sum type +func TestPrismWithComplexSumType(t *testing.T) { + + // Prism for Circle + circlePrism := MakePrism( + func(s Shape) Option[float64] { + if c, ok := s.(Circle); ok { + return O.Some(c.Radius) + } + return O.None[float64]() + }, + func(r float64) Shape { + return Circle{Radius: r} + }, + ) + + // Prism for Rectangle + rectanglePrism := MakePrism( + func(s Shape) Option[struct{ Width, Height float64 }] { + if r, ok := s.(Rectangle); ok { + return O.Some(struct{ Width, Height float64 }{r.Width, r.Height}) + } + return O.None[struct{ Width, Height float64 }]() + }, + func(dims struct{ Width, Height float64 }) Shape { + return Rectangle{Width: dims.Width, Height: dims.Height} + }, + ) + + // Test Circle prism + circle := Circle{Radius: 5.0} + radius := circlePrism.GetOption(circle) + assert.Equal(t, O.Some(5.0), radius) + + // Circle prism doesn't match Rectangle + rect := Rectangle{Width: 10, Height: 20} + radius = circlePrism.GetOption(rect) + assert.Equal(t, O.None[float64](), radius) + + // Rectangle prism matches Rectangle + dims := rectanglePrism.GetOption(rect) + assert.True(t, O.IsSome(dims)) + + // Test ReverseGet + newCircle := circlePrism.ReverseGet(7.5) + assert.Equal(t, Circle{Radius: 7.5}, newCircle) +} + +// TestEdgeCases tests various edge cases +func TestEdgeCases(t *testing.T) { + t.Run("prism with zero value", func(t *testing.T) { + somePrism := MakePrism( + F.Identity[Option[int]], + O.Some[int], + ) + + // Zero value should work fine + result := somePrism.GetOption(O.Some(0)) + assert.Equal(t, O.Some(0), result) + + opt := somePrism.ReverseGet(0) + assert.Equal(t, O.Some(0), opt) + }) + + t.Run("prism with empty string", func(t *testing.T) { + somePrism := MakePrism( + F.Identity[Option[string]], + O.Some[string], + ) + + result := somePrism.GetOption(O.Some("")) + assert.Equal(t, O.Some(""), result) + }) + + t.Run("identity prism with nil pointer", func(t *testing.T) { + type MyStruct struct{ Value int } + idPrism := Id[*MyStruct]() + + var nilPtr *MyStruct + result := idPrism.GetOption(nilPtr) + assert.Equal(t, O.Some(nilPtr), result) + }) +} + +// TestFromEncoding tests the FromEncoding prism with various base64 encodings +func TestFromEncoding(t *testing.T) { + t.Run("standard encoding - valid base64", func(t *testing.T) { + prism := FromEncoding(base64.StdEncoding) + + // Test decoding valid base64 + input := "SGVsbG8gV29ybGQ=" + result := prism.GetOption(input) + + assert.True(t, O.IsSome(result)) + decoded := O.GetOrElse(F.Constant([]byte{}))(result) + assert.Equal(t, []byte("Hello World"), decoded) + }) + + t.Run("standard encoding - invalid base64", func(t *testing.T) { + prism := FromEncoding(base64.StdEncoding) + + // Test decoding invalid base64 + invalid := "not-valid-base64!!!" + result := prism.GetOption(invalid) + + assert.True(t, O.IsNone(result)) + assert.Equal(t, O.None[[]byte](), result) + }) + + t.Run("standard encoding - encode bytes", func(t *testing.T) { + prism := FromEncoding(base64.StdEncoding) + + // Test encoding bytes to base64 + data := []byte("Hello World") + encoded := prism.ReverseGet(data) + + assert.Equal(t, "SGVsbG8gV29ybGQ=", encoded) + }) + + t.Run("standard encoding - round trip", func(t *testing.T) { + prism := FromEncoding(base64.StdEncoding) + + // Test round-trip: encode then decode + original := []byte("Test Data 123") + encoded := prism.ReverseGet(original) + decoded := prism.GetOption(encoded) + + assert.True(t, O.IsSome(decoded)) + result := O.GetOrElse(F.Constant([]byte{}))(decoded) + assert.Equal(t, original, result) + }) + + t.Run("URL encoding - valid base64", func(t *testing.T) { + prism := FromEncoding(base64.URLEncoding) + + // URL encoding uses - and _ instead of + and / + input := "SGVsbG8gV29ybGQ=" + result := prism.GetOption(input) + + assert.True(t, O.IsSome(result)) + decoded := O.GetOrElse(F.Constant([]byte{}))(result) + assert.Equal(t, []byte("Hello World"), decoded) + }) + + t.Run("URL encoding - with special characters", func(t *testing.T) { + prism := FromEncoding(base64.URLEncoding) + + // Test data that would use URL-safe characters + data := []byte("subjects?_d=1") + encoded := prism.ReverseGet(data) + decoded := prism.GetOption(encoded) + + assert.True(t, O.IsSome(decoded)) + result := O.GetOrElse(F.Constant([]byte{}))(decoded) + assert.Equal(t, data, result) + }) + + t.Run("raw standard encoding - no padding", func(t *testing.T) { + prism := FromEncoding(base64.RawStdEncoding) + + // RawStdEncoding omits padding + data := []byte("Hello") + encoded := prism.ReverseGet(data) + + // Should not have padding + assert.NotContains(t, encoded, "=") + + // Should still decode correctly + decoded := prism.GetOption(encoded) + assert.True(t, O.IsSome(decoded)) + result := O.GetOrElse(F.Constant([]byte{}))(decoded) + assert.Equal(t, data, result) + }) + + t.Run("empty byte slice", func(t *testing.T) { + prism := FromEncoding(base64.StdEncoding) + + // Test encoding empty byte slice + empty := []byte{} + encoded := prism.ReverseGet(empty) + assert.Equal(t, "", encoded) + + // Test decoding empty string + decoded := prism.GetOption("") + assert.True(t, O.IsSome(decoded)) + result := O.GetOrElse(F.Constant([]byte{1}))(decoded) + assert.Equal(t, []byte{}, result) + }) + + t.Run("binary data", func(t *testing.T) { + prism := FromEncoding(base64.StdEncoding) + + // Test with binary data (not just text) + binary := []byte{0x00, 0x01, 0x02, 0xFF, 0xFE, 0xFD} + encoded := prism.ReverseGet(binary) + decoded := prism.GetOption(encoded) + + assert.True(t, O.IsSome(decoded)) + result := O.GetOrElse(F.Constant([]byte{}))(decoded) + assert.Equal(t, binary, result) + }) + + t.Run("malformed base64 - wrong padding", func(t *testing.T) { + prism := FromEncoding(base64.StdEncoding) + + // Test with incorrect padding - too much padding + malformed := "SGVsbG8===" // Too much padding + result := prism.GetOption(malformed) + + // Should return None for malformed input + assert.True(t, O.IsNone(result)) + }) + + t.Run("malformed base64 - invalid characters", func(t *testing.T) { + prism := FromEncoding(base64.StdEncoding) + + // Test with invalid characters for standard encoding + invalid := "SGVs bG8@" + result := prism.GetOption(invalid) + + assert.True(t, O.IsNone(result)) + }) +} + +// TestFromEncodingWithSet tests using Set with FromEncoding prism +func TestFromEncodingWithSet(t *testing.T) { + prism := FromEncoding(base64.StdEncoding) + + t.Run("set new value on valid base64", func(t *testing.T) { + // Original encoded value + original := "SGVsbG8gV29ybGQ=" // "Hello World" + + // New data to set + newData := []byte("New Data") + setter := Set[string, []byte](newData) + + // Apply the setter + result := setter(prism)(original) + + // Should return the new data encoded + expected := prism.ReverseGet(newData) + assert.Equal(t, expected, result) + + // Verify it decodes to the new data + decoded := prism.GetOption(result) + assert.True(t, O.IsSome(decoded)) + assert.Equal(t, newData, O.GetOrElse(F.Constant([]byte{}))(decoded)) + }) + + t.Run("set on invalid base64 returns original", func(t *testing.T) { + // Invalid base64 string + invalid := "not-valid-base64!!!" + + // Try to set new data + newData := []byte("New Data") + setter := Set[string, []byte](newData) + + // Should return original unchanged + result := setter(prism)(invalid) + assert.Equal(t, invalid, result) + }) +} + +// TestFromEncodingComposition tests composing FromEncoding with other prisms +func TestFromEncodingComposition(t *testing.T) { + t.Run("compose with predicate prism", func(t *testing.T) { + // Create a prism that only accepts non-empty byte slices + nonEmptyPrism := FromPredicate(func(b []byte) bool { + return len(b) > 0 + }) + + // Compose with base64 prism + base64Prism := FromEncoding(base64.StdEncoding) + composed := F.Pipe1( + base64Prism, + Compose[string](nonEmptyPrism), + ) + + // Test with non-empty data + validEncoded := base64Prism.ReverseGet([]byte("data")) + result := composed.GetOption(validEncoded) + assert.True(t, O.IsSome(result)) + + // Test with empty data + emptyEncoded := base64Prism.ReverseGet([]byte{}) + result = composed.GetOption(emptyEncoded) + assert.True(t, O.IsNone(result)) + }) +} + +// TestFromEncodingPrismLaws tests that FromEncoding satisfies prism laws +func TestFromEncodingPrismLaws(t *testing.T) { + prism := FromEncoding(base64.StdEncoding) + + t.Run("law 1: GetOption(ReverseGet(a)) == Some(a)", func(t *testing.T) { + // For any byte slice, encoding then decoding should return the original + testData := [][]byte{ + []byte("Hello World"), + []byte(""), + []byte{0x00, 0xFF}, + []byte("Special chars: !@#$%^&*()"), + } + + for _, data := range testData { + encoded := prism.ReverseGet(data) + decoded := prism.GetOption(encoded) + + assert.True(t, O.IsSome(decoded)) + result := O.GetOrElse(F.Constant([]byte{}))(decoded) + assert.Equal(t, data, result) + } + }) + + t.Run("law 2: if GetOption(s) == Some(a), then ReverseGet(a) produces valid s", func(t *testing.T) { + // For valid base64 strings, decode then encode should produce valid base64 + validInputs := []string{ + "SGVsbG8gV29ybGQ=", + "", + "AQID", + } + + for _, input := range validInputs { + extracted := prism.GetOption(input) + if O.IsSome(extracted) { + data := O.GetOrElse(F.Constant([]byte{}))(extracted) + reencoded := prism.ReverseGet(data) + + // Re-decode to verify it's valid + redecoded := prism.GetOption(reencoded) + assert.True(t, O.IsSome(redecoded)) + + // The data should match + finalData := O.GetOrElse(F.Constant([]byte{}))(redecoded) + assert.Equal(t, data, finalData) + } + } + }) +} + +// TestParseURL tests the ParseURL prism with various URL formats +func TestParseURL(t *testing.T) { + urlPrism := ParseURL() + + t.Run("valid HTTP URL", func(t *testing.T) { + input := "https://example.com/path?query=value" + result := urlPrism.GetOption(input) + + assert.True(t, O.IsSome(result)) + parsed := O.GetOrElse(F.Constant((*url.URL)(nil)))(result) + assert.NotNil(t, parsed) + assert.Equal(t, "https", parsed.Scheme) + assert.Equal(t, "example.com", parsed.Host) + assert.Equal(t, "/path", parsed.Path) + assert.Equal(t, "query=value", parsed.RawQuery) + }) + + t.Run("valid HTTP URL without scheme", func(t *testing.T) { + input := "//example.com/path" + result := urlPrism.GetOption(input) + + assert.True(t, O.IsSome(result)) + parsed := O.GetOrElse(F.Constant((*url.URL)(nil)))(result) + assert.Equal(t, "example.com", parsed.Host) + }) + + t.Run("simple domain", func(t *testing.T) { + input := "example.com" + result := urlPrism.GetOption(input) + + assert.True(t, O.IsSome(result)) + parsed := O.GetOrElse(F.Constant((*url.URL)(nil)))(result) + assert.NotNil(t, parsed) + }) + + t.Run("URL with port", func(t *testing.T) { + input := "https://example.com:8080/path" + result := urlPrism.GetOption(input) + + assert.True(t, O.IsSome(result)) + parsed := O.GetOrElse(F.Constant((*url.URL)(nil)))(result) + assert.Equal(t, "example.com:8080", parsed.Host) + }) + + t.Run("URL with fragment", func(t *testing.T) { + input := "https://example.com/path#section" + result := urlPrism.GetOption(input) + + assert.True(t, O.IsSome(result)) + parsed := O.GetOrElse(F.Constant((*url.URL)(nil)))(result) + assert.Equal(t, "section", parsed.Fragment) + }) + + t.Run("URL with user info", func(t *testing.T) { + input := "https://user:pass@example.com/path" + result := urlPrism.GetOption(input) + + assert.True(t, O.IsSome(result)) + parsed := O.GetOrElse(F.Constant((*url.URL)(nil)))(result) + assert.NotNil(t, parsed.User) + }) + + t.Run("invalid URL with spaces", func(t *testing.T) { + input := "ht tp://invalid url" + result := urlPrism.GetOption(input) + + // url.Parse is lenient, so this might still parse + // The test verifies the behavior + _ = result + }) + + t.Run("empty string", func(t *testing.T) { + input := "" + result := urlPrism.GetOption(input) + + assert.True(t, O.IsSome(result)) + parsed := O.GetOrElse(F.Constant((*url.URL)(nil)))(result) + assert.NotNil(t, parsed) + }) + + t.Run("reverse get - URL to string", func(t *testing.T) { + u, _ := url.Parse("https://example.com/path?query=value") + str := urlPrism.ReverseGet(u) + + assert.Equal(t, "https://example.com/path?query=value", str) + }) + + t.Run("round trip", func(t *testing.T) { + original := "https://example.com:8080/path?q=v#frag" + parsed := urlPrism.GetOption(original) + + assert.True(t, O.IsSome(parsed)) + u := O.GetOrElse(F.Constant((*url.URL)(nil)))(parsed) + reconstructed := urlPrism.ReverseGet(u) + + // Parse both to compare (URL normalization may occur) + reparsed := urlPrism.GetOption(reconstructed) + assert.True(t, O.IsSome(reparsed)) + }) +} + +// TestParseURLWithSet tests using Set with ParseURL prism +func TestParseURLWithSet(t *testing.T) { + urlPrism := ParseURL() + + t.Run("set new URL on valid input", func(t *testing.T) { + original := "https://oldsite.com/path" + newURL, _ := url.Parse("https://newsite.com/newpath") + + setter := Set[string, *url.URL](newURL) + result := setter(urlPrism)(original) + + assert.Equal(t, "https://newsite.com/newpath", result) + }) +} + +// TestParseURLPrismLaws tests that ParseURL satisfies prism laws +func TestParseURLPrismLaws(t *testing.T) { + urlPrism := ParseURL() + + t.Run("law 1: GetOption(ReverseGet(a)) == Some(a)", func(t *testing.T) { + testURLs := []string{ + "https://example.com", + "https://example.com:8080/path?q=v", + "http://user:pass@example.com/path#frag", + } + + for _, urlStr := range testURLs { + u, _ := url.Parse(urlStr) + str := urlPrism.ReverseGet(u) + reparsed := urlPrism.GetOption(str) + + assert.True(t, O.IsSome(reparsed)) + } + }) +} + +// TestInstanceOf tests the InstanceOf prism with various types +func TestInstanceOf(t *testing.T) { + t.Run("extract int from any", func(t *testing.T) { + intPrism := InstanceOf[int]() + var value any = 42 + + result := intPrism.GetOption(value) + assert.True(t, O.IsSome(result)) + assert.Equal(t, 42, O.GetOrElse(F.Constant(0))(result)) + }) + + t.Run("extract string from any", func(t *testing.T) { + stringPrism := InstanceOf[string]() + var value any = "hello" + + result := stringPrism.GetOption(value) + assert.True(t, O.IsSome(result)) + assert.Equal(t, "hello", O.GetOrElse(F.Constant(""))(result)) + }) + + t.Run("type mismatch returns None", func(t *testing.T) { + intPrism := InstanceOf[int]() + var value any = "not an int" + + result := intPrism.GetOption(value) + assert.True(t, O.IsNone(result)) + }) + + t.Run("extract struct from any", func(t *testing.T) { + type Person struct { + Name string + Age int + } + + personPrism := InstanceOf[Person]() + var value any = Person{Name: "Alice", Age: 30} + + result := personPrism.GetOption(value) + assert.True(t, O.IsSome(result)) + person := O.GetOrElse(F.Constant(Person{}))(result) + assert.Equal(t, "Alice", person.Name) + assert.Equal(t, 30, person.Age) + }) + + t.Run("extract pointer type", func(t *testing.T) { + type Data struct{ Value int } + ptrPrism := InstanceOf[*Data]() + + data := &Data{Value: 42} + var value any = data + + result := ptrPrism.GetOption(value) + assert.True(t, O.IsSome(result)) + extracted := O.GetOrElse(F.Constant((*Data)(nil)))(result) + assert.Equal(t, 42, extracted.Value) + }) + + t.Run("nil value", func(t *testing.T) { + intPrism := InstanceOf[int]() + var value any = nil + + result := intPrism.GetOption(value) + assert.True(t, O.IsNone(result)) + }) + + t.Run("reverse get - T to any", func(t *testing.T) { + intPrism := InstanceOf[int]() + anyValue := intPrism.ReverseGet(42) + + assert.Equal(t, any(42), anyValue) + }) + + t.Run("round trip", func(t *testing.T) { + stringPrism := InstanceOf[string]() + original := "test" + + anyValue := stringPrism.ReverseGet(original) + extracted := stringPrism.GetOption(anyValue) + + assert.True(t, O.IsSome(extracted)) + assert.Equal(t, original, O.GetOrElse(F.Constant(""))(extracted)) + }) + + t.Run("zero value", func(t *testing.T) { + intPrism := InstanceOf[int]() + var value any = 0 + + result := intPrism.GetOption(value) + assert.True(t, O.IsSome(result)) + assert.Equal(t, 0, O.GetOrElse(F.Constant(-1))(result)) + }) +} + +// TestInstanceOfWithSet tests using Set with InstanceOf prism +func TestInstanceOfWithSet(t *testing.T) { + t.Run("set new value on matching type", func(t *testing.T) { + intPrism := InstanceOf[int]() + var original any = 42 + + setter := Set[any, int](100) + result := setter(intPrism)(original) + + assert.Equal(t, any(100), result) + }) + + t.Run("set on non-matching type returns original", func(t *testing.T) { + intPrism := InstanceOf[int]() + var original any = "not an int" + + setter := Set[any, int](100) + result := setter(intPrism)(original) + + assert.Equal(t, original, result) + }) +} + +// TestInstanceOfPrismLaws tests that InstanceOf satisfies prism laws +func TestInstanceOfPrismLaws(t *testing.T) { + t.Run("law 1: GetOption(ReverseGet(a)) == Some(a)", func(t *testing.T) { + intPrism := InstanceOf[int]() + testValues := []int{0, 42, -10, 999} + + for _, val := range testValues { + anyVal := intPrism.ReverseGet(val) + extracted := intPrism.GetOption(anyVal) + + assert.True(t, O.IsSome(extracted)) + assert.Equal(t, val, O.GetOrElse(F.Constant(0))(extracted)) + } + }) +} + +// TestParseDate tests the ParseDate prism with various date formats +func TestParseDate(t *testing.T) { + t.Run("ISO date format - valid", func(t *testing.T) { + datePrism := ParseDate("2006-01-02") + input := "2024-03-15" + + result := datePrism.GetOption(input) + assert.True(t, O.IsSome(result)) + + parsed := O.GetOrElse(F.Constant(time.Time{}))(result) + assert.Equal(t, 2024, parsed.Year()) + assert.Equal(t, time.March, parsed.Month()) + assert.Equal(t, 15, parsed.Day()) + }) + + t.Run("ISO date format - invalid", func(t *testing.T) { + datePrism := ParseDate("2006-01-02") + input := "not-a-date" + + result := datePrism.GetOption(input) + assert.True(t, O.IsNone(result)) + }) + + t.Run("RFC3339 format", func(t *testing.T) { + datePrism := ParseDate(time.RFC3339) + input := "2024-03-15T10:30:00Z" + + result := datePrism.GetOption(input) + assert.True(t, O.IsSome(result)) + + parsed := O.GetOrElse(F.Constant(time.Time{}))(result) + assert.Equal(t, 2024, parsed.Year()) + assert.Equal(t, 10, parsed.Hour()) + assert.Equal(t, 30, parsed.Minute()) + }) + + t.Run("custom format", func(t *testing.T) { + datePrism := ParseDate("02/01/2006") + input := "15/03/2024" + + result := datePrism.GetOption(input) + assert.True(t, O.IsSome(result)) + + parsed := O.GetOrElse(F.Constant(time.Time{}))(result) + assert.Equal(t, 2024, parsed.Year()) + assert.Equal(t, time.March, parsed.Month()) + assert.Equal(t, 15, parsed.Day()) + }) + + t.Run("wrong format returns None", func(t *testing.T) { + datePrism := ParseDate("2006-01-02") + input := "03/15/2024" // MM/DD/YYYY instead of YYYY-MM-DD + + result := datePrism.GetOption(input) + assert.True(t, O.IsNone(result)) + }) + + t.Run("reverse get - format date", func(t *testing.T) { + datePrism := ParseDate("2006-01-02") + date := time.Date(2024, 3, 15, 0, 0, 0, 0, time.UTC) + + str := datePrism.ReverseGet(date) + assert.Equal(t, "2024-03-15", str) + }) + + t.Run("round trip", func(t *testing.T) { + datePrism := ParseDate("2006-01-02") + original := "2024-03-15" + + parsed := datePrism.GetOption(original) + assert.True(t, O.IsSome(parsed)) + + date := O.GetOrElse(F.Constant(time.Time{}))(parsed) + formatted := datePrism.ReverseGet(date) + + assert.Equal(t, original, formatted) + }) + + t.Run("empty string", func(t *testing.T) { + datePrism := ParseDate("2006-01-02") + result := datePrism.GetOption("") + + assert.True(t, O.IsNone(result)) + }) + + t.Run("time with timezone", func(t *testing.T) { + datePrism := ParseDate(time.RFC3339) + input := "2024-03-15T10:30:00+05:00" + + result := datePrism.GetOption(input) + assert.True(t, O.IsSome(result)) + + parsed := O.GetOrElse(F.Constant(time.Time{}))(result) + _, offset := parsed.Zone() + assert.Equal(t, 5*3600, offset) // 5 hours in seconds + }) +} + +// TestParseDateWithSet tests using Set with ParseDate prism +func TestParseDateWithSet(t *testing.T) { + datePrism := ParseDate("2006-01-02") + + t.Run("set new date on valid input", func(t *testing.T) { + original := "2024-03-15" + newDate := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) + + setter := Set[string, time.Time](newDate) + result := setter(datePrism)(original) + + assert.Equal(t, "2025-01-01", result) + }) + + t.Run("set on invalid date returns original", func(t *testing.T) { + invalid := "not-a-date" + newDate := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) + + setter := Set[string, time.Time](newDate) + result := setter(datePrism)(invalid) + + assert.Equal(t, invalid, result) + }) +} + +// TestParseDatePrismLaws tests that ParseDate satisfies prism laws +func TestParseDatePrismLaws(t *testing.T) { + datePrism := ParseDate("2006-01-02") + + t.Run("law 1: GetOption(ReverseGet(a)) == Some(a)", func(t *testing.T) { + testDates := []time.Time{ + time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC), + time.Date(2024, 12, 31, 0, 0, 0, 0, time.UTC), + time.Date(2000, 6, 15, 0, 0, 0, 0, time.UTC), + } + + for _, date := range testDates { + str := datePrism.ReverseGet(date) + reparsed := datePrism.GetOption(str) + + assert.True(t, O.IsSome(reparsed)) + parsed := O.GetOrElse(F.Constant(time.Time{}))(reparsed) + + // Compare date components (ignore time/location) + assert.Equal(t, date.Year(), parsed.Year()) + assert.Equal(t, date.Month(), parsed.Month()) + assert.Equal(t, date.Day(), parsed.Day()) + } + }) + + t.Run("law 2: if GetOption(s) == Some(a), then ReverseGet(a) produces valid s", func(t *testing.T) { + validInputs := []string{ + "2024-01-01", + "2024-12-31", + "2000-06-15", + } + + for _, input := range validInputs { + extracted := datePrism.GetOption(input) + if O.IsSome(extracted) { + date := O.GetOrElse(F.Constant(time.Time{}))(extracted) + reformatted := datePrism.ReverseGet(date) + + // Re-parse to verify it's valid + reparsed := datePrism.GetOption(reformatted) + assert.True(t, O.IsSome(reparsed)) + + // Should produce the same date + assert.Equal(t, input, reformatted) + } + } + }) +} + +// TestDeref tests the Deref prism with pointer dereferencing +func TestDeref(t *testing.T) { + derefPrism := Deref[int]() + + t.Run("dereference non-nil pointer", func(t *testing.T) { + value := 42 + ptr := &value + + result := derefPrism.GetOption(ptr) + assert.True(t, O.IsSome(result)) + + extracted := O.GetOrElse(F.Constant((*int)(nil)))(result) + assert.NotNil(t, extracted) + assert.Equal(t, 42, *extracted) + }) + + t.Run("dereference nil pointer", func(t *testing.T) { + var nilPtr *int + + result := derefPrism.GetOption(nilPtr) + assert.True(t, O.IsNone(result)) + }) + + t.Run("reverse get returns pointer unchanged", func(t *testing.T) { + value := 42 + ptr := &value + + result := derefPrism.ReverseGet(ptr) + assert.Equal(t, ptr, result) + assert.Equal(t, 42, *result) + }) + + t.Run("reverse get with nil pointer", func(t *testing.T) { + var nilPtr *int + + result := derefPrism.ReverseGet(nilPtr) + assert.Nil(t, result) + }) + + t.Run("with string pointers", func(t *testing.T) { + stringDeref := Deref[string]() + + str := "hello" + ptr := &str + + result := stringDeref.GetOption(ptr) + assert.True(t, O.IsSome(result)) + + extracted := O.GetOrElse(F.Constant((*string)(nil)))(result) + assert.NotNil(t, extracted) + assert.Equal(t, "hello", *extracted) + }) + + t.Run("with struct pointers", func(t *testing.T) { + type Person struct { + Name string + Age int + } + + personDeref := Deref[Person]() + + person := Person{Name: "Alice", Age: 30} + ptr := &person + + result := personDeref.GetOption(ptr) + assert.True(t, O.IsSome(result)) + + extracted := O.GetOrElse(F.Constant((*Person)(nil)))(result) + assert.NotNil(t, extracted) + assert.Equal(t, "Alice", extracted.Name) + assert.Equal(t, 30, extracted.Age) + }) +} + +// TestDerefWithSet tests using Set with Deref prism +func TestDerefWithSet(t *testing.T) { + derefPrism := Deref[int]() + + t.Run("set on non-nil pointer", func(t *testing.T) { + value := 42 + ptr := &value + + newValue := 100 + newPtr := &newValue + + setter := Set[*int, *int](newPtr) + result := setter(derefPrism)(ptr) + + assert.NotNil(t, result) + assert.Equal(t, 100, *result) + }) + + t.Run("set on nil pointer returns nil", func(t *testing.T) { + var nilPtr *int + + newValue := 100 + newPtr := &newValue + + setter := Set[*int, *int](newPtr) + result := setter(derefPrism)(nilPtr) + + assert.Nil(t, result) + }) +} + +// TestDerefPrismLaws tests that Deref satisfies prism laws +func TestDerefPrismLaws(t *testing.T) { + derefPrism := Deref[int]() + + t.Run("law 1: GetOption(ReverseGet(a)) == Some(a)", func(t *testing.T) { + value := 42 + ptr := &value + + reversed := derefPrism.ReverseGet(ptr) + extracted := derefPrism.GetOption(reversed) + + assert.True(t, O.IsSome(extracted)) + result := O.GetOrElse(F.Constant((*int)(nil)))(extracted) + assert.Equal(t, ptr, result) + }) +} + +// TestFromEither tests the FromEither prism with Either types +func TestFromEither(t *testing.T) { + t.Run("extract from Right", func(t *testing.T) { + prism := FromEither[error, int]() + + success := E.Right[error](42) + result := prism.GetOption(success) + + assert.True(t, O.IsSome(result)) + assert.Equal(t, 42, O.GetOrElse(F.Constant(0))(result)) + }) + + t.Run("extract from Left returns None", func(t *testing.T) { + prism := FromEither[error, int]() + + failure := E.Left[int](errors.New("failed")) + result := prism.GetOption(failure) + + assert.True(t, O.IsNone(result)) + }) + + t.Run("reverse get wraps into Right", func(t *testing.T) { + prism := FromEither[error, int]() + + wrapped := prism.ReverseGet(100) + + assert.True(t, E.IsRight(wrapped)) + value := E.GetOrElse(func(error) int { return 0 })(wrapped) + assert.Equal(t, 100, value) + }) + + t.Run("with string error type", func(t *testing.T) { + prism := FromEither[string, int]() + + success := E.Right[string](42) + result := prism.GetOption(success) + + assert.True(t, O.IsSome(result)) + assert.Equal(t, 42, O.GetOrElse(F.Constant(0))(result)) + + failure := E.Left[int]("error message") + result = prism.GetOption(failure) + + assert.True(t, O.IsNone(result)) + }) + + t.Run("with custom error type", func(t *testing.T) { + type CustomError struct { + Code int + Message string + } + + prism := FromEither[CustomError, string]() + + success := E.Right[CustomError]("success") + result := prism.GetOption(success) + + assert.True(t, O.IsSome(result)) + assert.Equal(t, "success", O.GetOrElse(F.Constant(""))(result)) + + failure := E.Left[string](CustomError{Code: 404, Message: "Not Found"}) + result = prism.GetOption(failure) + + assert.True(t, O.IsNone(result)) + }) + + t.Run("round trip", func(t *testing.T) { + prism := FromEither[error, int]() + + original := 42 + wrapped := prism.ReverseGet(original) + extracted := prism.GetOption(wrapped) + + assert.True(t, O.IsSome(extracted)) + assert.Equal(t, original, O.GetOrElse(F.Constant(0))(extracted)) + }) +} + +// TestFromEitherWithSet tests using Set with FromEither prism +func TestFromEitherWithSet(t *testing.T) { + prism := FromEither[error, int]() + + t.Run("set on Right value", func(t *testing.T) { + success := E.Right[error](42) + + setter := Set[E.Either[error, int], int](100) + result := setter(prism)(success) + + assert.True(t, E.IsRight(result)) + value := E.GetOrElse(func(error) int { return 0 })(result) + assert.Equal(t, 100, value) + }) + + t.Run("set on Left value returns original", func(t *testing.T) { + failure := E.Left[int](errors.New("failed")) + + setter := Set[E.Either[error, int], int](100) + result := setter(prism)(failure) + + assert.True(t, E.IsLeft(result)) + // Original error is preserved + assert.Equal(t, failure, result) + }) +} + +// TestFromEitherComposition tests composing FromEither with other prisms +func TestFromEitherComposition(t *testing.T) { + t.Run("compose with predicate prism", func(t *testing.T) { + // Create a prism that only accepts positive numbers + positivePrism := FromPredicate(func(n int) bool { + return n > 0 + }) + + // Compose with Either prism + eitherPrism := FromEither[error, int]() + composed := F.Pipe1( + eitherPrism, + Compose[E.Either[error, int]](positivePrism), + ) + + // Test with Right positive + success := E.Right[error](42) + result := composed.GetOption(success) + assert.True(t, O.IsSome(result)) + + // Test with Right non-positive + nonPositive := E.Right[error](-5) + result = composed.GetOption(nonPositive) + assert.True(t, O.IsNone(result)) + + // Test with Left + failure := E.Left[int](errors.New("error")) + result = composed.GetOption(failure) + assert.True(t, O.IsNone(result)) + }) +} + +// TestFromEitherPrismLaws tests that FromEither satisfies prism laws +func TestFromEitherPrismLaws(t *testing.T) { + prism := FromEither[error, int]() + + t.Run("law 1: GetOption(ReverseGet(a)) == Some(a)", func(t *testing.T) { + testValues := []int{0, 42, -10, 999} + + for _, val := range testValues { + wrapped := prism.ReverseGet(val) + extracted := prism.GetOption(wrapped) + + assert.True(t, O.IsSome(extracted)) + assert.Equal(t, val, O.GetOrElse(F.Constant(0))(extracted)) + } + }) + + t.Run("law 2: if GetOption(s) == Some(a), then ReverseGet(a) produces valid Either", func(t *testing.T) { + success := E.Right[error](42) + + extracted := prism.GetOption(success) + if O.IsSome(extracted) { + value := O.GetOrElse(F.Constant(0))(extracted) + rewrapped := prism.ReverseGet(value) + + // Should be Right + assert.True(t, E.IsRight(rewrapped)) + + // Re-extract to verify + reextracted := prism.GetOption(rewrapped) + assert.True(t, O.IsSome(reextracted)) + assert.Equal(t, value, O.GetOrElse(F.Constant(0))(reextracted)) + } + }) +} diff --git a/v2/optics/prism/prisms.go b/v2/optics/prism/prisms.go new file mode 100644 index 0000000..066904a --- /dev/null +++ b/v2/optics/prism/prisms.go @@ -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]) +} diff --git a/v2/optics/prism/traversal.go b/v2/optics/prism/traversal.go index d13f5bf..d1e80f9 100644 --- a/v2/optics/prism/traversal.go +++ b/v2/optics/prism/traversal.go @@ -20,7 +20,43 @@ import ( 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]( fof func(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, sa.GetOption, O.Fold( + // If prism doesn't match, return the original value lifted into HKTS F.Nullary2(F.Constant(s), fof), + // If prism matches, apply f to the extracted value and map back func(a A) HKTS { return fmap(f(a), func(a A) S { return prismModify(F.Constant1[A](a), sa, s) diff --git a/v2/optics/prism/types.go b/v2/optics/prism/types.go new file mode 100644 index 0000000..e4a1914 --- /dev/null +++ b/v2/optics/prism/types.go @@ -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] +)