mirror of
https://github.com/IBM/fp-go.git
synced 2025-11-23 22:14:53 +02:00
299 lines
9.3 KiB
Go
299 lines
9.3 KiB
Go
// 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 http
|
|
|
|
import (
|
|
"fmt"
|
|
"io"
|
|
"mime"
|
|
H "net/http"
|
|
"net/url"
|
|
"regexp"
|
|
|
|
A "github.com/IBM/fp-go/v2/array"
|
|
E "github.com/IBM/fp-go/v2/either"
|
|
"github.com/IBM/fp-go/v2/errors"
|
|
F "github.com/IBM/fp-go/v2/function"
|
|
O "github.com/IBM/fp-go/v2/option"
|
|
P "github.com/IBM/fp-go/v2/pair"
|
|
R "github.com/IBM/fp-go/v2/record/generic"
|
|
)
|
|
|
|
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
|
|
body []byte
|
|
url *url.URL
|
|
}
|
|
)
|
|
|
|
var (
|
|
// 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 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)
|
|
|
|
// 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(
|
|
P.Head[string, map[string]string],
|
|
E.FromPredicate(isJSONMimeType, errors.OnSome[string]("mimetype [%s] is not a valid JSON content type")),
|
|
)),
|
|
)
|
|
|
|
// 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(
|
|
GetHeader,
|
|
R.Lookup[H.Header](HeaderContentType),
|
|
O.Chain(A.First[string]),
|
|
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.
|
|
//
|
|
// 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 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 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 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)
|
|
defer bodyRdr.Close()
|
|
// try to access body content
|
|
body, _ := io.ReadAll(bodyRdr)
|
|
// return an error with comprehensive information
|
|
return &HttpError{statusCode: resp.StatusCode, headers: GetHeader(resp).Clone(), body: body, url: resp.Request.URL}
|
|
}
|