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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ on:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:

jobs:
lint-and-test:
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ The client offers several configuration options:
- **WithAPIKey**: Your Friendly Captcha API key.
- **WithSitekey**: Your Friendly Captcha sitekey.
- **WithStrictMode**: (Optional) In case the client was not able to verify the captcha response at all (for example if there is a network failure or a mistake in configuration), by default the `VerifyCaptchaResponse` returns `True` regardless. By passing `WithStrictMode(true)`, it will return `false` instead: every response needs to be strictly verified.
- **WithSiteverifyEndpoint**: (Optional) The endpoint URL for the site verification API. Shorthands `eu` or `global` are also accepted. Default is `global`.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Maybe this should still be here but marked deprecated and then removed in the subsequent release?

- **WithAPIEndpoint**: (Optional) The endpoint for the site verification API. Shorthands `eu` or `global` are also accepted. Default is `global`.

## Development

Expand Down
62 changes: 46 additions & 16 deletions client.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,17 @@ package friendlycaptcha
import (
"fmt"
"net/http"
"net/url"
)

// A ClientOption is a function that can be passed to NewClient to configure a new Client.
type ClientOption func(*Client) error

// A client for the Friendly Captcha API, see also the API docs at https://developer.friendlycaptcha.com
type Client struct {
APIKey string
Sitekey string
SiteverifyEndpoint string
APIKey string
Sitekey string
APIEndpoint string
// If Strict is set to true only strictly verified captcha response will be allowed.
// For example: if your server can not reach the Friendly Captcha endpoint, it will still advise to accept the response
// regardless.
Expand All @@ -29,19 +30,19 @@ type Client struct {
const ResponseFormFieldName = "frc-captcha-response"

const (
globalSiteverifyEndpointURL = "https://global.frcapi.com/api/v2/captcha/siteverify"
euSiteverifyEndpointURL = "https://eu.frcapi.com/api/v2/captcha/siteverify"
globalAPIEndpoint = "https://global.frcapi.com"
euAPIEndpoint = "https://eu.frcapi.com"
)

// NewClient creates a new Friendly Captcha client with the given options.
func NewClient(opts ...ClientOption) (*Client, error) {
const (
defaultSiteverifyEndpoint = globalSiteverifyEndpointURL
defaultAPIEndpoint = globalAPIEndpoint
)

c := &Client{
HTTPClient: http.DefaultClient,
SiteverifyEndpoint: defaultSiteverifyEndpoint,
HTTPClient: http.DefaultClient,
APIEndpoint: defaultAPIEndpoint,
}

// Loop through each option
Expand Down Expand Up @@ -88,17 +89,46 @@ func WithStrictMode(strict bool) ClientOption {
}
}

// Takes a full URL, or the shorthands `"global"` or `"eu"`.
// WithAPIEndpoint sets the API endpoint domain for the client.
// Takes a domain without path (e.g., "https://global.frcapi.com"), or the shorthands "global" or "eu".
func WithAPIEndpoint(apiEndpoint string) ClientOption {
return func(c *Client) error {
switch apiEndpoint {
case "global":
apiEndpoint = globalAPIEndpoint
case "eu":
apiEndpoint = euAPIEndpoint
case "":
return fmt.Errorf("apiEndpoint must not be empty")
}
c.APIEndpoint = apiEndpoint
return nil
}
}

// WithSiteverifyEndpoint sets the API endpoint for the client.
// Deprecated: Use WithAPIEndpoint instead. This function strips the path from the URL and calls WithAPIEndpoint.
// Takes a full URL, or the shorthands "global" or "eu".
func WithSiteverifyEndpoint(siteverifyEndpoint string) ClientOption {
return func(c *Client) error {
if siteverifyEndpoint == "global" {
siteverifyEndpoint = globalSiteverifyEndpointURL
} else if siteverifyEndpoint == "eu" {
siteverifyEndpoint = euSiteverifyEndpointURL
} else if siteverifyEndpoint == "" {
if siteverifyEndpoint == "" {
return fmt.Errorf("siteverifyEndpoint must not be empty")
}
c.SiteverifyEndpoint = siteverifyEndpoint
return nil

// Handle shorthands
if siteverifyEndpoint == "global" || siteverifyEndpoint == "eu" {
return WithAPIEndpoint(siteverifyEndpoint)(c)
}

// Parse URL to extract scheme and host (domain without path)
u, err := url.Parse(siteverifyEndpoint)
if err != nil {
return fmt.Errorf("invalid siteverifyEndpoint URL: %w", err)
}

// Construct the API endpoint without path
apiEndpoint := u.Scheme + "://" + u.Host

return WithAPIEndpoint(apiEndpoint)(c)
}
}
170 changes: 170 additions & 0 deletions client_options_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
package friendlycaptcha

import (
"testing"

"github.com/stretchr/testify/assert"
)

func TestWithAPIEndpoint(t *testing.T) {
t.Parallel()
tests := []struct {
name string
input string
expected string
expectError bool
}{
{
name: "global shorthand",
input: "global",
expected: "https://global.frcapi.com",
},
{
name: "eu shorthand",
input: "eu",
expected: "https://eu.frcapi.com",
},
{
name: "full domain https",
input: "https://custom.example.com",
expected: "https://custom.example.com",
},
{
name: "full domain http",
input: "http://localhost:1090",
expected: "http://localhost:1090",
},
{
name: "empty string",
input: "",
expectError: true,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

client, err := NewClient(
WithAPIKey("test-key"),
WithAPIEndpoint(tt.input),
)

if tt.expectError {
assert.Error(t, err)
return
}

assert.NoError(t, err)
assert.Equal(t, tt.expected, client.APIEndpoint)
})
}
}

func TestWithSiteverifyEndpoint_Deprecated(t *testing.T) {
t.Parallel()

tests := []struct {
name string
input string
expected string
expectError bool
}{
{
name: "global shorthand",
input: "global",
expected: "https://global.frcapi.com",
},
{
name: "eu shorthand",
input: "eu",
expected: "https://eu.frcapi.com",
},
{
name: "full URL with path - strips path",
input: "https://global.frcapi.com/api/v2/captcha/siteverify",
expected: "https://global.frcapi.com",
},
{
name: "full URL without path",
input: "https://custom.example.com",
expected: "https://custom.example.com",
},
{
name: "localhost with path - strips path",
input: "http://localhost:1090/api/v2/captcha/siteverify",
expected: "http://localhost:1090",
},
{
name: "localhost without path",
input: "http://localhost:1090",
expected: "http://localhost:1090",
},
{
name: "https with port and path",
input: "https://example.com:8080/some/path",
expected: "https://example.com:8080",
},
{
name: "empty string",
input: "",
expectError: true,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

client, err := NewClient(
WithAPIKey("test-key"),
WithSiteverifyEndpoint(tt.input),
)

if tt.expectError {
assert.Error(t, err)
return
}

assert.NoError(t, err)
assert.Equal(t, tt.expected, client.APIEndpoint)
})
}
}

func TestWithSiteverifyEndpoint_BackwardCompatibility(t *testing.T) {
t.Parallel()

// Test that the old usage still works
client, err := NewClient(
WithAPIKey("test-key"),
WithSiteverifyEndpoint("https://eu.frcapi.com/api/v2/captcha/siteverify"),
)

assert.NoError(t, err)
assert.Equal(t, "https://eu.frcapi.com", client.APIEndpoint)
}

func TestWithAPIEndpoint_DefaultValue(t *testing.T) {
// Test that the default is set correctly
client, err := NewClient(
WithAPIKey("test-key"),
)

assert.NoError(t, err)
assert.Equal(t, "https://global.frcapi.com", client.APIEndpoint)
}

func TestWithAPIEndpoint_OverridesWithSiteverifyEndpoint(t *testing.T) {
t.Parallel()

// Test that when both are provided, the last one wins
client, err := NewClient(
WithAPIKey("test-key"),
WithSiteverifyEndpoint("https://global.frcapi.com/api/v2/captcha/siteverify"),
WithAPIEndpoint("eu"),
)

assert.NoError(t, err)
assert.Equal(t, "https://eu.frcapi.com", client.APIEndpoint)
}
77 changes: 75 additions & 2 deletions client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,16 @@ type TestCase struct {
Name string `json:"name"`
Response string `json:"response"`
Expectation struct {
ShouldAccept bool `json:"should_accept"`
ShouldAccept bool `json:"should_accept"`
WasAbleToVerify bool `json:"was_able_to_verify"`
IsClientError bool `json:"is_client_error"`
} `json:"expectation"`
Strict bool `json:"strict"`
Strict bool `json:"strict"`
SiteverifyResponse json.RawMessage `json:"siteverify_response"`
}

type SuccessSiteverifyResponse struct {
Data VerifyResponseData `json:"data"`
}

func loadTestCasesFromServer() (TestCasesFile, error) {
Expand Down Expand Up @@ -90,6 +97,72 @@ func TestSDKWithMockServer(t *testing.T) {
shouldAccept,
fmt.Sprintf("Expected shouldAccept to be: %v, got: %v", expectedShouldAccept, shouldAccept),
)

assert.Equal(
t,
test.Expectation.WasAbleToVerify,
result.WasAbleToVerify(),
fmt.Sprintf("Expected WasAbleToVerify to be: %v, got: %v", test.Expectation.WasAbleToVerify, result.WasAbleToVerify()),
)

assert.Equal(
t,
test.Expectation.IsClientError,
result.IsErrorDueToClientError(),
fmt.Sprintf("Expected IsClientError to be: %v, got: %v", test.Expectation.IsClientError, result.IsErrorDueToClientError()),
)

if result.Success {
var expectedResponse SuccessSiteverifyResponse
err := json.Unmarshal(test.SiteverifyResponse, &expectedResponse)
if err != nil {
t.Fatalf("Failed to unmarshal expected siteverify response: %v", err)
}

exp := expectedResponse.Data
res := result.response.Data

assert.Equal(
t,
exp.EventID,
res.EventID,
"Event ID does not match expected value",
)

assert.Equal(
t,
exp.Challenge,
res.Challenge,
"Challenge data does not match expected value",
)

assert.Equal(
t,
exp.RiskIntelligence,
res.RiskIntelligence,
"Risk Intelligence data does not match expected value",
)

// Check two specific fields:
assert.Equal(
t,
exp.RiskIntelligence.V.Client.HeaderUserAgent,
res.RiskIntelligence.V.Client.HeaderUserAgent,
)
assert.Equal(
t,
exp.RiskIntelligence.V.Client.Browser.V.ID,
res.RiskIntelligence.V.Client.Browser.V.ID,
)

if exp.RiskIntelligence.Valid {
assert.Contains(
t,
string(res.RiskIntelligenceRaw.V),
"header_user_agent",
)
}
}
})
}
}
3 changes: 2 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
module github.com/friendlycaptcha/friendly-captcha-go

go 1.18
go 1.22.12

require github.com/stretchr/testify v1.8.4

require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/guregu/null/v6 v6.0.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
Loading